diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index f612d50..73474a5 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -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; diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index 3a15bbf..d614391 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -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;