fix: eliminate multi-sequencer drift with time-based step calculation
Problem: two sequencers at different BPMs (e.g. 80 and 160) would drift apart over time because each used an independent step counter (step++) that accumulated floating-point rounding errors. Fix: derive step/position from audio clock time (Tone.now()), not from an incrementing counter. Step = floor(elapsed * rate) % numSteps. This makes timing mathematically exact regardless of how long it runs. Also: - Sequencer note-off uses Tone.getContext().setTimeout() (audio-thread) instead of Tone.Transport.scheduleOnce() which needs Transport running - Clock runs at 2x rate for tighter step edge detection - PianoRoll uses same time-based position calculation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -247,19 +247,23 @@ export default function PianoRollWidget({ moduleId }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second
|
||||
const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note
|
||||
let tickCount = 0;
|
||||
let currentNote = null; // track currently sounding note for on/off transitions
|
||||
const sixteenthRate = (bpm * 4) / 60;
|
||||
const startTime = Tone.now();
|
||||
let currentNote = null;
|
||||
let lastPos = -1;
|
||||
|
||||
const clock = new Tone.Clock(() => {
|
||||
const rawPos = tickCount * 0.25;
|
||||
const clock = new Tone.Clock((time) => {
|
||||
// Derive position from audio clock — no counter drift
|
||||
const elapsed = time - startTime;
|
||||
const rawPos = elapsed * sixteenthRate * 0.25; // in beats
|
||||
const pos = loop ? rawPos % totalBeats : rawPos;
|
||||
const prevRawPos = (tickCount - 1) * 0.25;
|
||||
const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
|
||||
tickCount++;
|
||||
|
||||
const looped = tickCount > 1 && pos < prevPos;
|
||||
// Quantize to sixteenth resolution for consistent note detection
|
||||
const quantPos = Math.floor(pos * 4) / 4;
|
||||
|
||||
// Detect loop wrap
|
||||
const looped = lastPos >= 0 && quantPos < lastPos;
|
||||
lastPos = quantPos;
|
||||
|
||||
if (!loop && rawPos >= totalBeats) {
|
||||
if (currentNote) {
|
||||
@@ -270,7 +274,6 @@ export default function PianoRollWidget({ moduleId }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update ref, not state — visual follows via RAF
|
||||
playPosRef.current = pos;
|
||||
|
||||
if (looped && currentNote) {
|
||||
@@ -279,7 +282,7 @@ export default function PianoRollWidget({ moduleId }) {
|
||||
}
|
||||
|
||||
const allNotes = notesRef.current;
|
||||
const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration);
|
||||
const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
|
||||
|
||||
if (activeNote) {
|
||||
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
|
||||
@@ -292,7 +295,7 @@ export default function PianoRollWidget({ moduleId }) {
|
||||
currentNote = null;
|
||||
}
|
||||
}
|
||||
}, sixteenthRate);
|
||||
}, sixteenthRate * 2); // 2x rate for tighter detection
|
||||
|
||||
clock.start();
|
||||
partRef.current = clock;
|
||||
|
||||
@@ -70,34 +70,35 @@ export default function SequencerWidget({ moduleId }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sixteenthRate = (bpm * 4) / 60;
|
||||
const stepDuration = 1 / sixteenthRate;
|
||||
let step = 0;
|
||||
let noteOffId = null;
|
||||
const sixteenthRate = (bpm * 4) / 60; // Hz
|
||||
const stepDuration = 1 / sixteenthRate; // seconds per step
|
||||
const startTime = Tone.now();
|
||||
let lastStepIdx = -1;
|
||||
|
||||
const clock = new Tone.Clock((time) => {
|
||||
const stepIdx = step % numSteps;
|
||||
step++;
|
||||
// Derive step from audio clock time — no counter drift
|
||||
const elapsed = time - startTime;
|
||||
const stepIdx = Math.floor(elapsed * sixteenthRate) % numSteps;
|
||||
|
||||
// Only trigger on step change
|
||||
if (stepIdx === lastStepIdx) return;
|
||||
lastStepIdx = stepIdx;
|
||||
|
||||
const s = stepsRef.current[stepIdx];
|
||||
if (!s) return;
|
||||
|
||||
// Update ref (not state!) — visual follows via RAF
|
||||
currentStepRef.current = stepIdx;
|
||||
|
||||
if (s.gate) {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
||||
// Schedule note-off using Tone.Draw or Tone.context
|
||||
// Use the audio clock's time for precise scheduling
|
||||
if (noteOffId !== null) {
|
||||
try { Tone.getTransport().clear(noteOffId); } catch {}
|
||||
}
|
||||
noteOffId = Tone.getTransport().scheduleOnce(() => {
|
||||
// Note-off via audio-thread timeout (not main-thread setTimeout)
|
||||
Tone.getContext().setTimeout(() => {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||
}, time + stepDuration * 0.8);
|
||||
}, stepDuration * 0.8);
|
||||
} else {
|
||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||
}
|
||||
}, sixteenthRate);
|
||||
}, sixteenthRate * 2); // Run clock at 2x rate for tighter step detection
|
||||
|
||||
clock.start();
|
||||
clockRef.current = clock;
|
||||
|
||||
Reference in New Issue
Block a user