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); };
}, [draw]);
// Playback
// Playback — uses independent Tone.Clock so multiple pianorolls/sequencers
// don't interfere with each other via the global Transport
useEffect(() => {
if (!state.isRunning) {
if (partRef.current) {
@@ -244,61 +245,63 @@ export default function PianoRollWidget({ moduleId }) {
return;
}
const transport = Tone.getTransport();
transport.bpm.value = bpm;
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
// Ensure Transport is at position 0 before scheduling
if (transport.state === 'started') {
transport.stop();
const clock = new Tone.Clock(() => {
const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats)
const pos = loop ? rawPos % totalBeats : rawPos;
const prevRawPos = (tickCount - 1) * 0.25;
const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
tickCount++;
// Detect loop wrap (position jumped backwards)
const looped = tickCount > 1 && pos < prevPos;
// Stop at end if not looping
if (!loop && rawPos >= totalBeats) {
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
transport.position = 0;
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths)
// This lets the Transport BPM control actual playback speed
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) => {
setSequencerSignals(moduleId, midiToFreq(ev.note), true);
// Note-off: convert duration beats to musical time for proper BPM-relative timing
const durSixteenths = Math.round(ev.dur * 4);
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9;
transport.scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
}, noteOffTime);
}, 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);
setPlayPos(-1);
return;
}
}, 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 () => {
clearInterval(posInterval);
if (partRef.current) {
try { partRef.current.stop(); } catch {}
try { partRef.current.dispose(); } catch {}

View File

@@ -19,7 +19,7 @@ const DEFAULT_STEPS = [
export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const [currentStep, setCurrentStep] = useState(-1);
const seqRef = useRef(null);
const clockRef = useRef(null);
const stepsRef = useRef(null);
// Init steps data
@@ -36,59 +36,54 @@ export default function SequencerWidget({ moduleId }) {
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(() => {
if (!state.isRunning) {
if (seqRef.current) {
try { seqRef.current.stop(); } catch {}
try { seqRef.current.dispose(); } catch {}
seqRef.current = null;
if (clockRef.current) {
try { clockRef.current.stop(); } catch {}
try { clockRef.current.dispose(); } catch {}
clockRef.current = null;
}
setCurrentStep(-1);
return;
}
const transport = Tone.getTransport();
transport.bpm.value = bpm;
// Independent clock at 16th-note rate
const sixteenthRate = (bpm * 4) / 60; // Hz
let step = 0;
// Ensure Transport is at position 0
if (transport.state === 'started') {
transport.stop();
}
transport.position = 0;
const seq = new Tone.Sequence((time, stepIdx) => {
const clock = new Tone.Clock((time) => {
const stepIdx = step % numSteps;
step++;
const s = stepsRef.current[stepIdx];
if (!s) return;
setCurrentStep(stepIdx);
if (s.gate) {
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);
}, time + Tone.Time('16n').toSeconds() * 0.8);
}, stepDuration * 0.8 * 1000);
} else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}
}, Array.from({ length: numSteps }, (_, i) => i), '16n');
}, sixteenthRate);
seq.start(0);
transport.start();
seqRef.current = seq;
clock.start();
clockRef.current = clock;
return () => {
if (seqRef.current) {
try { seqRef.current.stop(); } catch {}
try { seqRef.current.dispose(); } catch {}
seqRef.current = null;
if (clockRef.current) {
try { clockRef.current.stop(); } catch {}
try { clockRef.current.dispose(); } catch {}
clockRef.current = null;
}
};
}, [state.isRunning, moduleId, numSteps]);
// Update BPM live
useEffect(() => {
if (state.isRunning) Tone.getTransport().bpm.value = bpm;
}, [bpm]);
}, [state.isRunning, moduleId, numSteps, bpm]);
const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };