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:
Jose Luis
2026-03-21 17:42:30 +01:00
parent b91b35f23d
commit 9dba156961
2 changed files with 32 additions and 28 deletions

View File

@@ -247,19 +247,23 @@ export default function PianoRollWidget({ moduleId }) {
return; return;
} }
const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second const sixteenthRate = (bpm * 4) / 60;
const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note const startTime = Tone.now();
let tickCount = 0; let currentNote = null;
let currentNote = null; // track currently sounding note for on/off transitions let lastPos = -1;
const clock = new Tone.Clock(() => { const clock = new Tone.Clock((time) => {
const rawPos = tickCount * 0.25; // 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 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 (!loop && rawPos >= totalBeats) {
if (currentNote) { if (currentNote) {
@@ -270,7 +274,6 @@ export default function PianoRollWidget({ moduleId }) {
return; return;
} }
// Update ref, not state — visual follows via RAF
playPosRef.current = pos; playPosRef.current = pos;
if (looped && currentNote) { if (looped && currentNote) {
@@ -279,7 +282,7 @@ export default function PianoRollWidget({ moduleId }) {
} }
const allNotes = notesRef.current; 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 (activeNote) {
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) { if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
@@ -292,7 +295,7 @@ export default function PianoRollWidget({ moduleId }) {
currentNote = null; currentNote = null;
} }
} }
}, sixteenthRate); }, sixteenthRate * 2); // 2x rate for tighter detection
clock.start(); clock.start();
partRef.current = clock; partRef.current = clock;

View File

@@ -70,34 +70,35 @@ export default function SequencerWidget({ moduleId }) {
return; return;
} }
const sixteenthRate = (bpm * 4) / 60; const sixteenthRate = (bpm * 4) / 60; // Hz
const stepDuration = 1 / sixteenthRate; const stepDuration = 1 / sixteenthRate; // seconds per step
let step = 0; const startTime = Tone.now();
let noteOffId = null; let lastStepIdx = -1;
const clock = new Tone.Clock((time) => { const clock = new Tone.Clock((time) => {
const stepIdx = step % numSteps; // Derive step from audio clock time — no counter drift
step++; 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]; const s = stepsRef.current[stepIdx];
if (!s) return; if (!s) return;
// Update ref (not state!) — visual follows via RAF
currentStepRef.current = stepIdx; currentStepRef.current = stepIdx;
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
// Schedule note-off using Tone.Draw or Tone.context // Note-off via audio-thread timeout (not main-thread setTimeout)
// Use the audio clock's time for precise scheduling Tone.getContext().setTimeout(() => {
if (noteOffId !== null) {
try { Tone.getTransport().clear(noteOffId); } catch {}
}
noteOffId = Tone.getTransport().scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, time + stepDuration * 0.8); }, stepDuration * 0.8);
} else { } else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
} }
}, sixteenthRate); }, sixteenthRate * 2); // Run clock at 2x rate for tighter step detection
clock.start(); clock.start();
clockRef.current = clock; clockRef.current = clock;