Add step sequencer (16-step with note/gate editing) and piano roll (canvas-based MIDI editor with draw/erase tools). Includes a Megaman-style melody in C minor. Chiptune preset now uses piano roll instead of keyboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
164 lines
6.0 KiB
JavaScript
164 lines
6.0 KiB
JavaScript
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 (
|
|
<div style={{ width: W + 4, overflow: 'hidden' }}>
|
|
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
|
|
{/* 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 (
|
|
<g key={i}>
|
|
{/* Background */}
|
|
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
|
|
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
|
|
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
|
|
/>
|
|
{/* Note bar */}
|
|
{s.gate && (
|
|
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
|
|
rx={1}
|
|
fill={isActive ? '#00e5ff' : '#0088aa'}
|
|
opacity={0.9}
|
|
/>
|
|
)}
|
|
{/* Inactive marker */}
|
|
{!s.gate && (
|
|
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
|
|
stroke="#333" strokeWidth={1.5} />
|
|
)}
|
|
{/* Note name */}
|
|
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
|
|
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
|
|
{noteLabel(s.midi)}
|
|
</text>
|
|
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
|
|
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
|
|
fill="transparent" style={{ cursor: 'pointer' }}
|
|
onClick={() => changeNote(i, 1)}
|
|
/>
|
|
<rect x={x} y={CELL_H / 3} width={CELL_W} height={CELL_H / 3}
|
|
fill="transparent" style={{ cursor: 'pointer' }}
|
|
onClick={() => toggleGate(i)}
|
|
/>
|
|
<rect x={x} y={CELL_H * 2 / 3} width={CELL_W} height={CELL_H / 3}
|
|
fill="transparent" style={{ cursor: 'pointer' }}
|
|
onClick={() => changeNote(i, -1)}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
{/* Playhead line */}
|
|
{currentStep >= 0 && (
|
|
<line
|
|
x1={currentStep * CELL_W + CELL_W / 2} y1={0}
|
|
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H}
|
|
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
|
|
/>
|
|
)}
|
|
</svg>
|
|
<div style={{ fontSize: 8, color: '#555', textAlign: 'center', marginTop: 2 }}>
|
|
↑top/↓bot: pitch · mid: toggle
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|