import React, { useState, useEffect, useRef, useCallback } from 'react'; import * as Tone from 'tone'; import { state, updateModuleParam, emit } from '../engine/state.js'; import { setSequencerSignals, getAudioNode } from '../engine/audioEngine.js'; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } // Default notes: C minor pentatonic pattern const DEFAULT_STEPS = [ { midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true }, { midi: 58, gate: true }, { midi: 60, gate: true }, { midi: 63, gate: false }, { midi: 65, gate: true }, { midi: 67, gate: true }, { midi: 72, gate: true }, { midi: 70, gate: false }, { midi: 67, gate: true }, ]; export default function SequencerWidget({ moduleId }) { const mod = state.modules.find(m => m.id === moduleId); const [currentStep, setCurrentStep] = useState(-1); const seqRef = useRef(null); const stepsRef = useRef(null); // Init steps data const numSteps = parseInt(mod?.params?.steps || '16'); if (!mod?.params?._steps) { const initial = DEFAULT_STEPS.slice(0, numSteps); while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); if (mod) { mod.params._steps = initial; } } const steps = mod?.params?._steps || DEFAULT_STEPS; stepsRef.current = steps; const bpm = mod?.params?.bpm ?? 140; // Start/stop sequencer when audio engine runs useEffect(() => { if (!state.isRunning) { if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; } setCurrentStep(-1); return; } Tone.getTransport().bpm.value = bpm; const seq = new Tone.Sequence((time, stepIdx) => { const s = stepsRef.current[stepIdx]; if (!s) return; setCurrentStep(stepIdx); if (s.gate) { setSequencerSignals(moduleId, midiToFreq(s.midi), true); Tone.getTransport().scheduleOnce(() => { setSequencerSignals(moduleId, midiToFreq(s.midi), false); }, time + Tone.Time('16n').toSeconds() * 0.8); } else { setSequencerSignals(moduleId, midiToFreq(s.midi), false); } }, Array.from({ length: numSteps }, (_, i) => i), '16n'); seq.start(0); if (Tone.getTransport().state !== 'started') { Tone.getTransport().start(); } seqRef.current = seq; return () => { if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; } }; }, [state.isRunning, moduleId, numSteps]); // Update BPM live useEffect(() => { if (state.isRunning) Tone.getTransport().bpm.value = bpm; }, [bpm]); const toggleGate = (idx) => { steps[idx] = { ...steps[idx], gate: !steps[idx].gate }; updateModuleParam(moduleId, '_steps', [...steps]); stepsRef.current = steps; }; const changeNote = (idx, delta) => { const newMidi = Math.max(36, Math.min(96, steps[idx].midi + delta)); steps[idx] = { ...steps[idx], midi: newMidi }; updateModuleParam(moduleId, '_steps', [...steps]); stepsRef.current = steps; emit(); }; const CELL_W = 18; const CELL_H = 50; const W = CELL_W * numSteps; const H = CELL_H + 16; return (
{/* Steps */} {steps.slice(0, numSteps).map((s, i) => { const x = i * CELL_W; const isActive = i === currentStep; const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); return ( {/* Background */} {/* Note bar */} {s.gate && ( )} {/* Inactive marker */} {!s.gate && ( )} {/* Note name */} {noteLabel(s.midi)} {/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */} changeNote(i, 1)} /> toggleGate(i)} /> changeNote(i, -1)} /> ); })} {/* Playhead line */} {currentStep >= 0 && ( )}
↑top/↓bot: pitch · mid: toggle
); }