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:
@@ -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();
|
||||
}
|
||||
transport.position = 0;
|
||||
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++;
|
||||
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
// Detect loop wrap (position jumped backwards)
|
||||
const looped = tickCount > 1 && pos < prevPos;
|
||||
|
||||
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);
|
||||
// Stop at end if not looping
|
||||
if (!loop && rawPos >= totalBeats) {
|
||||
if (currentNote) {
|
||||
setSequencerSignals(moduleId, 0, false);
|
||||
currentNote = null;
|
||||
}
|
||||
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 {}
|
||||
|
||||
Reference in New Issue
Block a user