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:
Jose Luis
2026-03-21 01:30:03 +01:00
parent 4a003f2af2
commit 65a89e2b59
7 changed files with 647 additions and 23 deletions

View 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>
);
}