Last active
October 16, 2025 12:06
-
-
Save jcppman/dff044dd58c04e77d7c9dad969c7c064 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| class DrumMachine { | |
| // Timing constants for unified event loop | |
| static readonly PPQ = 192; // Pulses per quarter note (Tone.js constant) | |
| static readonly TICK_RESOLUTION = 12; // Master loop resolution | |
| static readonly MUSICAL_STEP_DIVISION = 2; // 8th notes (2 per quarter note) | |
| static readonly STEPS_PER_MUSICAL_STEP = | |
| DrumMachine.PPQ / DrumMachine.MUSICAL_STEP_DIVISION / DrumMachine.TICK_RESOLUTION; // 8th note per musical step | |
| static readonly LATENCY_COMPENSATION = 0.1; // Metronome timing compensation | |
| static readonly PROGRESS_UPDATE_PRECISION = 2; // Progress update frequency multiplier | |
| /** | |
| * current step: how many step driver loop has been passed since start | |
| */ | |
| calculateMusicalStep(currentStep: number): number { | |
| return Math.floor(currentStep / DrumMachine.STEPS_PER_MUSICAL_STEP); | |
| } | |
| createMasterStepDriver(): Tone.Loop { | |
| return new Tone.Loop(time => { | |
| const sequenceLength = this.getSequenceLength(this.selectedSeqNo); | |
| const totalSteps = sequenceLength * DrumMachine.STEPS_PER_MUSICAL_STEP; | |
| if (this.currentStep === null) { | |
| if (this.isRecording) { | |
| // Initialize precount: Set currentStep to negative value for precount period | |
| // This creates 8 eighth-note precount (PREP_BEAT_SLOTS × STEPS_PER_MUSICAL_STEP) | |
| const precountDuration = PREP_BEAT_SLOTS * DrumMachine.STEPS_PER_MUSICAL_STEP; // 64 steps | |
| this.currentStep = -precountDuration; | |
| } else { | |
| this.currentStep = 0; | |
| } | |
| } else { | |
| // Unified step advancement: works for both precount (negative) and normal playback (positive) | |
| // Since precount duration (64) < minimum totalSteps (256), modulo handles both cases correctly | |
| this.currentStep = (this.currentStep + 1) % totalSteps; | |
| } | |
| const currentStep = this.currentStep; | |
| // PRECOUNT PHASE: currentStep < 0 | |
| // During precount, we only schedule metronome, no samples or sequence switching | |
| if (currentStep < 0) { | |
| // Calculate musical position within precount for metronome timing | |
| const precountDuration = PREP_BEAT_SLOTS * DrumMachine.STEPS_PER_MUSICAL_STEP; // 64 steps | |
| const precountPosition = currentStep + precountDuration; // 0 to 63 | |
| const musicalStep = this.calculateMusicalStep(precountPosition); | |
| // Metronome during precount - every quarter note (every 2 eighth notes) | |
| if (precountPosition % DrumMachine.STEPS_PER_MUSICAL_STEP === 0 && musicalStep % 2 === 0) { | |
| const beat = Math.floor(musicalStep / 2) % 4; // 0=downbeat, 1-3=other beats | |
| this.scheduleMetronome(beat, time); | |
| } | |
| // Skip all other processing during precount | |
| return; | |
| } | |
| // NORMAL RECORDING/PLAYBACK PHASE: currentStep >= 0 | |
| // All normal sequencer functionality runs here | |
| // 0. Check for pending sequence switch at the beginning of each sequence loop | |
| if (this.pendingSequenceIndex !== null && currentStep === 0) { | |
| this.setSelectedSeqNo(this.pendingSequenceIndex); | |
| this.clearPendingSequence(time); | |
| } | |
| // 1. Sample sequencing - ONLY on musical grid (every 8 steps = 96 ticks = 8th notes) | |
| const isRightOnMusicalStep = currentStep % DrumMachine.STEPS_PER_MUSICAL_STEP === 0; | |
| const musicalStep = this.calculateMusicalStep(currentStep); | |
| if (isRightOnMusicalStep) { | |
| this.latestMusicalStepTime = time; | |
| this.scheduleCurrentStepSamples(musicalStep, time); | |
| } | |
| // 2. Metronome during playback - every quarter note (every 2 eighth notes) | |
| if ( | |
| isRightOnMusicalStep && | |
| musicalStep % 2 === 0 && | |
| (this.metronomeEnabled || this.isRecording) | |
| ) { | |
| const beat = this.calculateBeatFromStep(currentStep); | |
| this.scheduleMetronome(beat, time); | |
| } | |
| // 3. Cursor updates - every step for smooth movement | |
| this.updateCursor(currentStep, time); | |
| // 4. Progress updates - fine-grained based on PROGRESS_UPDATE_PRECISION | |
| if (currentStep % DrumMachine.PROGRESS_UPDATE_PRECISION === 0) { | |
| this.updateProgress(currentStep, time); | |
| } | |
| }, `${DrumMachine.TICK_RESOLUTION}i`); // 12-tick master loop resolution | |
| } | |
| /** | |
| * Get the current quantized step for recording | |
| * | |
| * RECORDING STEP CALCULATION: | |
| * - During precount (currentStep < 0): Always return 0 (will record at sequence start) | |
| * - During recording (currentStep >= 0): Return current musical step within sequence | |
| * - This ensures samples clicked during precount register at the beginning of the loop | |
| */ | |
| getQuantizedCurrentStep(): number { | |
| if (this.currentStep === null) return 0; | |
| // During precount (negative currentStep), samples will be recorded at sequence start | |
| if (this.currentStep < 0) { | |
| return 0; | |
| } | |
| let musicalStep = this.calculateMusicalStep(CassetteEditor.currentStep); | |
| const timeAfterLastMusicalStep = | |
| Tone.now() - (this.latestMusicalStepTime ?? 0) - DrumMachine.LATENCY_COMPENSATION; | |
| const musicalStepDuration = bpmToSeconds(Tone.Transport.bpm.value, DrumMachine.MUSICAL_STEP_DIVISION); // 8th note duration | |
| if (timeAfterLastMusicalStep > (musicalStepDuration * 3) / 4) { | |
| musicalStep += 1; // Round up to next step if past 3/4 point | |
| } | |
| const sequenceLength = this.getSequenceLength(this.selectedSeqNo); | |
| return musicalStep % sequenceLength; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 在 Sample button 被 click 時的 handler | |
| const onSampleMouseDown = useCallback( | |
| (index: number, sample?: Partial<Sample | null>) => { | |
| const samplePlayer = drumMachine.getSamplePlayer(index); | |
| // always play the sample first, highest priority | |
| if (samplePlayer.isLoaded && sample) { | |
| const startAt = sample.start ? Number(sample.start) : undefined; | |
| const duration = sample.end ? sample.end - Number(sample.start) : undefined; | |
| samplePlayer.play('preview', { | |
| offset: startAt, | |
| duration: duration, | |
| }); | |
| } | |
| if (isRecording) { | |
| // Get the quantized step at the moment of pressing the sample button | |
| const step = drumMachine.getQuantizedCurrentStep(); | |
| drumMachine.updateSequence(curSeq, index, step, true); | |
| updateRecordingState(); | |
| } | |
| startTransition(() => { | |
| if (!sample) { | |
| openSamplePanel?.(); | |
| } | |
| if (index !== selectedSampleIndex) { | |
| drumMachine.setSelectedSampleIndex(index); | |
| setSelectedSampleIndex(index); | |
| } | |
| }); | |
| }, | |
| [updateCassetteSequencerSeqs, updateRecordingState, isRecording, openSamplePanel, selectedSampleIndex], | |
| ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment