Skip to content

Instantly share code, notes, and snippets.

@jcppman
Last active October 16, 2025 12:06
Show Gist options
  • Select an option

  • Save jcppman/dff044dd58c04e77d7c9dad969c7c064 to your computer and use it in GitHub Desktop.

Select an option

Save jcppman/dff044dd58c04e77d7c9dad969c7c064 to your computer and use it in GitHub Desktop.
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;
}
}
// 在 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