fix: use independent Tone.Clock per sequencer/pianoroll instance

Replace shared global Tone.Transport with per-instance Tone.Clock so
multiple sequencers and pianorolls run independently without interfering
with each other's timing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 04:45:44 +01:00
parent 36eb31a652
commit 64280874ea
2 changed files with 79 additions and 81 deletions

View File

@@ -232,7 +232,8 @@ export default function PianoRollWidget({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]); }, [draw]);
// Playback // Playback — uses independent Tone.Clock so multiple pianorolls/sequencers
// don't interfere with each other via the global Transport
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (partRef.current) { if (partRef.current) {
@@ -244,61 +245,63 @@ export default function PianoRollWidget({ moduleId }) {
return; return;
} }
const transport = Tone.getTransport(); const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second
transport.bpm.value = bpm; const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note
let tickCount = 0;
let currentNote = null; // track currently sounding note for on/off transitions
// Ensure Transport is at position 0 before scheduling const clock = new Tone.Clock(() => {
if (transport.state === 'started') { const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats)
transport.stop(); const pos = loop ? rawPos % totalBeats : rawPos;
} const prevRawPos = (tickCount - 1) * 0.25;
transport.position = 0; const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
tickCount++;
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths) // Detect loop wrap (position jumped backwards)
// This lets the Transport BPM control actual playback speed const looped = tickCount > 1 && pos < prevPos;
const events = notesRef.current.map(n => {
// Convert beats to bars:quarters:sixteenths notation
const totalSixteenths = Math.round(n.start * 4);
const barNum = Math.floor(totalSixteenths / 16);
const remainder = totalSixteenths % 16;
const quarterNum = Math.floor(remainder / 4);
const sixteenthNum = remainder % 4;
return {
time: `${barNum}:${quarterNum}:${sixteenthNum}`,
note: n.note,
dur: n.duration,
};
});
const part = new Tone.Part((time, ev) => { // Stop at end if not looping
setSequencerSignals(moduleId, midiToFreq(ev.note), true); if (!loop && rawPos >= totalBeats) {
// Note-off: convert duration beats to musical time for proper BPM-relative timing if (currentNote) {
const durSixteenths = Math.round(ev.dur * 4); setSequencerSignals(moduleId, 0, false);
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9; currentNote = null;
transport.scheduleOnce(() => { }
setSequencerSignals(moduleId, midiToFreq(ev.note), false); setPlayPos(-1);
}, noteOffTime); return;
}, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }]));
part.loop = loop;
part.loopEnd = `${bars}m`;
part.start(0);
partRef.current = part;
// Start Transport fresh from position 0
transport.start();
// Track playhead position
const posInterval = setInterval(() => {
if (transport.state === 'started') {
const pos = transport.seconds;
const beatDuration = 60 / bpm;
const currentBeat = (pos / beatDuration) % totalBeats;
setPlayPos(currentBeat);
} }
}, 30);
setPlayPos(pos);
// Force note-off on loop boundary for clean retrigger
if (looped && currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
// Find the note active at this position
const allNotes = notesRef.current;
const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration);
if (activeNote) {
// New note or different note → trigger
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
currentNote = activeNote;
}
// Same note sustaining → do nothing
} else {
// No note at this position → gate off
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
}
}, sixteenthRate);
clock.start();
partRef.current = clock;
return () => { return () => {
clearInterval(posInterval);
if (partRef.current) { if (partRef.current) {
try { partRef.current.stop(); } catch {} try { partRef.current.stop(); } catch {}
try { partRef.current.dispose(); } catch {} try { partRef.current.dispose(); } catch {}

View File

@@ -19,7 +19,7 @@ const DEFAULT_STEPS = [
export default function SequencerWidget({ moduleId }) { export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const [currentStep, setCurrentStep] = useState(-1); const [currentStep, setCurrentStep] = useState(-1);
const seqRef = useRef(null); const clockRef = useRef(null);
const stepsRef = useRef(null); const stepsRef = useRef(null);
// Init steps data // Init steps data
@@ -36,59 +36,54 @@ export default function SequencerWidget({ moduleId }) {
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
// Start/stop sequencer when audio engine runs // Start/stop sequencer when audio engine runs — uses independent Tone.Clock
// so multiple sequencers don't interfere with each other via the global Transport
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (seqRef.current) { if (clockRef.current) {
try { seqRef.current.stop(); } catch {} try { clockRef.current.stop(); } catch {}
try { seqRef.current.dispose(); } catch {} try { clockRef.current.dispose(); } catch {}
seqRef.current = null; clockRef.current = null;
} }
setCurrentStep(-1); setCurrentStep(-1);
return; return;
} }
const transport = Tone.getTransport(); // Independent clock at 16th-note rate
transport.bpm.value = bpm; const sixteenthRate = (bpm * 4) / 60; // Hz
let step = 0;
// Ensure Transport is at position 0 const clock = new Tone.Clock((time) => {
if (transport.state === 'started') { const stepIdx = step % numSteps;
transport.stop(); step++;
}
transport.position = 0;
const seq = new Tone.Sequence((time, stepIdx) => {
const s = stepsRef.current[stepIdx]; const s = stepsRef.current[stepIdx];
if (!s) return; if (!s) return;
setCurrentStep(stepIdx); setCurrentStep(stepIdx);
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
transport.scheduleOnce(() => { // Schedule note-off at 80% of step duration
const stepDuration = 1 / sixteenthRate;
setTimeout(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, time + Tone.Time('16n').toSeconds() * 0.8); }, stepDuration * 0.8 * 1000);
} else { } else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false); setSequencerSignals(moduleId, midiToFreq(s.midi), false);
} }
}, Array.from({ length: numSteps }, (_, i) => i), '16n'); }, sixteenthRate);
seq.start(0); clock.start();
transport.start(); clockRef.current = clock;
seqRef.current = seq;
return () => { return () => {
if (seqRef.current) { if (clockRef.current) {
try { seqRef.current.stop(); } catch {} try { clockRef.current.stop(); } catch {}
try { seqRef.current.dispose(); } catch {} try { clockRef.current.dispose(); } catch {}
seqRef.current = null; clockRef.current = null;
} }
}; };
}, [state.isRunning, moduleId, numSteps]); }, [state.isRunning, moduleId, numSteps, bpm]);
// Update BPM live
useEffect(() => {
if (state.isRunning) Tone.getTransport().bpm.value = bpm;
}, [bpm]);
const toggleGate = (idx) => { const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate }; steps[idx] = { ...steps[idx], gate: !steps[idx].gate };