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); };
|
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 {}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user