diff --git a/src/components/PianoRollWidget.jsx b/src/components/PianoRollWidget.jsx index 8927561..2585d8a 100644 --- a/src/components/PianoRollWidget.jsx +++ b/src/components/PianoRollWidget.jsx @@ -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 {} diff --git a/src/components/SequencerWidget.jsx b/src/components/SequencerWidget.jsx index e87dc1d..897d087 100644 --- a/src/components/SequencerWidget.jsx +++ b/src/components/SequencerWidget.jsx @@ -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 };