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