feat: add sequencer, piano roll modules with pre-composed chiptune melody
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>
This commit is contained in:
163
src/components/SequencerWidget.jsx
Normal file
163
src/components/SequencerWidget.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user