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,361 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals } from '../engine/audioEngine.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const BLACK_KEYS = [1, 3, 6, 8, 10];
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function isBlack(midi) { return BLACK_KEYS.includes(midi % 12); }
// Chiptune melody: Megaman-style theme in C minor
// Format: { note: MIDI, start: beats, duration: beats }
const MEGA_MELODY = [
// Bar 1: Opening riff
{ note: 72, start: 0, duration: 0.25 }, // C5
{ note: 72, start: 0.5, duration: 0.25 },
{ note: 75, start: 1, duration: 0.5 }, // Eb5
{ note: 72, start: 1.75, duration: 0.25 },
{ note: 70, start: 2, duration: 0.5 }, // Bb4
{ note: 67, start: 2.75, duration: 0.25 }, // G4
{ note: 65, start: 3, duration: 0.5 }, // F4
{ note: 63, start: 3.5, duration: 0.5 }, // Eb4
// Bar 2: Response phrase
{ note: 60, start: 4, duration: 0.5 }, // C4
{ note: 63, start: 4.5, duration: 0.25 }, // Eb4
{ note: 65, start: 5, duration: 0.25 }, // F4
{ note: 67, start: 5.5, duration: 0.75 }, // G4
{ note: 70, start: 6.5, duration: 0.5 }, // Bb4
{ note: 72, start: 7, duration: 1 }, // C5
// Bar 3: Climb up
{ note: 67, start: 8, duration: 0.25 }, // G4
{ note: 70, start: 8.5, duration: 0.25 }, // Bb4
{ note: 72, start: 9, duration: 0.25 }, // C5
{ note: 75, start: 9.5, duration: 0.5 }, // Eb5
{ note: 77, start: 10, duration: 0.5 }, // F5
{ note: 79, start: 10.5, duration: 0.5 }, // G5
{ note: 77, start: 11, duration: 0.25 }, // F5
{ note: 75, start: 11.5, duration: 0.5 }, // Eb5
// Bar 4: Resolution
{ note: 72, start: 12, duration: 0.5 }, // C5
{ note: 70, start: 12.5, duration: 0.25 }, // Bb4
{ note: 67, start: 13, duration: 0.5 }, // G4
{ note: 63, start: 13.75, duration: 0.25 }, // Eb4
{ note: 60, start: 14, duration: 1 }, // C4
{ note: 60, start: 15.5, duration: 0.5 }, // C4 (pickup)
];
const ROLL_W = 500;
const ROLL_H = 200;
const KEY_W = 24;
const MIN_NOTE = 48; // C3
const MAX_NOTE = 84; // C6
const NOTE_RANGE = MAX_NOTE - MIN_NOTE;
const ROW_H = ROLL_H / NOTE_RANGE;
export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null);
const partRef = useRef(null);
const [playPos, setPlayPos] = useState(-1);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null);
const rafRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140;
const bars = parseInt(mod?.params?.bars || '4');
const loop = mod?.params?.loop !== 'off';
const totalBeats = bars * 4;
// Init notes
if (!mod?.params?._notes) {
if (mod) mod.params._notes = [...MEGA_MELODY];
}
const notes = mod?.params?._notes || MEGA_MELODY;
const notesRef = useRef(notes);
notesRef.current = notes;
const beatW = (ROLL_W - KEY_W) / totalBeats;
// Draw the piano roll
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = '#06060e';
ctx.fillRect(0, 0, w, h);
// Piano keys
for (let i = 0; i < NOTE_RANGE; i++) {
const midi = MAX_NOTE - 1 - i;
const y = i * ROW_H;
const black = isBlack(midi);
// Row background
ctx.fillStyle = black ? '#0a0a16' : '#0e0e1a';
ctx.fillRect(KEY_W, y, w - KEY_W, ROW_H);
// Grid lines
ctx.strokeStyle = '#151525';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(KEY_W, y); ctx.lineTo(w, y);
ctx.stroke();
// Key label
ctx.fillStyle = black ? '#1a1a30' : '#222240';
ctx.fillRect(0, y, KEY_W, ROW_H);
ctx.strokeStyle = '#0a0a14';
ctx.strokeRect(0, y, KEY_W, ROW_H);
if (midi % 12 === 0) { // C notes
ctx.fillStyle = '#4466aa';
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.fillText(`C${Math.floor(midi / 12) - 1}`, KEY_W / 2, y + ROW_H / 2 + 2.5);
}
}
// Beat grid lines
for (let b = 0; b <= totalBeats; b++) {
const x = KEY_W + b * beatW;
ctx.strokeStyle = b % 4 === 0 ? '#2a2a50' : b % 1 === 0 ? '#151525' : '#101020';
ctx.lineWidth = b % 4 === 0 ? 1 : 0.5;
ctx.beginPath();
ctx.moveTo(x, 0); ctx.lineTo(x, h);
ctx.stroke();
}
// Draw notes
const currentNotes = notesRef.current;
for (const n of currentNotes) {
if (n.note < MIN_NOTE || n.note >= MAX_NOTE) continue;
const row = MAX_NOTE - 1 - n.note;
const x = KEY_W + n.start * beatW;
const nw = n.duration * beatW;
const y = row * ROW_H;
// Note body
const gradient = ctx.createLinearGradient(x, y, x, y + ROW_H);
gradient.addColorStop(0, '#00ccff');
gradient.addColorStop(1, '#0066aa');
ctx.fillStyle = gradient;
ctx.fillRect(x + 0.5, y + 0.5, Math.max(nw - 1, 2), ROW_H - 1);
// Note border
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 0.5;
ctx.strokeRect(x + 0.5, y + 0.5, Math.max(nw - 1, 2), ROW_H - 1);
// Note label for wider notes
if (nw > 15) {
ctx.fillStyle = '#fff';
ctx.font = '6px monospace';
ctx.textAlign = 'left';
ctx.fillText(NOTE_NAMES[n.note % 12], x + 2, y + ROW_H / 2 + 2);
}
}
// Playhead
if (playPos >= 0 && playPos < totalBeats) {
const px = KEY_W + playPos * beatW;
ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px, 0); ctx.lineTo(px, h);
ctx.stroke();
// Glow
ctx.strokeStyle = 'rgba(255,102,68,0.2)';
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(px, 0); ctx.lineTo(px, h);
ctx.stroke();
}
// Currently drawing note preview
if (drawingRef.current) {
const d = drawingRef.current;
const row = MAX_NOTE - 1 - d.note;
const x = KEY_W + d.start * beatW;
const nw = d.duration * beatW;
ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
}
}, [totalBeats, beatW, playPos]);
// Animation loop
useEffect(() => {
const animate = () => {
draw();
rafRef.current = requestAnimationFrame(animate);
};
animate();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]);
// Playback
useEffect(() => {
if (!state.isRunning) {
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
setPlayPos(-1);
return;
}
Tone.getTransport().bpm.value = bpm;
// Build Tone.Part from notes
const events = notesRef.current.map(n => ({
time: `0:0:${n.start * 4}`, // convert beats to 16th notes for Tone
note: n.note,
dur: n.duration,
}));
// Use a simpler approach: schedule directly
const part = new Tone.Part((time, ev) => {
setSequencerSignals(moduleId, midiToFreq(ev.note), true);
Tone.getTransport().scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
}, time + Tone.Time(`0:0:${ev.dur * 4}`).toSeconds() * 0.9);
}, events.map(ev => [Tone.Time(ev.time).toSeconds(), { note: ev.note, dur: ev.dur }]));
part.loop = loop;
part.loopEnd = `${bars}m`;
part.start(0);
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
partRef.current = part;
// Track playhead position
const posInterval = setInterval(() => {
if (Tone.getTransport().state === 'started') {
const pos = Tone.getTransport().seconds;
const beatDuration = 60 / bpm;
const currentBeat = (pos / beatDuration) % totalBeats;
setPlayPos(currentBeat);
}
}, 30);
return () => {
clearInterval(posInterval);
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
};
}, [state.isRunning, moduleId, bpm, bars, loop]);
// Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (mx < KEY_W) return; // Clicked on piano keys
const beat = (mx - KEY_W) / beatW;
const noteIdx = MAX_NOTE - 1 - Math.floor(my / ROW_H);
if (noteIdx < MIN_NOTE || noteIdx >= MAX_NOTE) return;
const snappedBeat = Math.floor(beat * 4) / 4; // Snap to 16th
if (tool === 'erase') {
// Remove any note at this position
const filtered = notes.filter(n =>
!(n.note === noteIdx && snappedBeat >= n.start && snappedBeat < n.start + n.duration)
);
if (filtered.length !== notes.length) {
mod.params._notes = filtered;
notesRef.current = filtered;
emit();
}
} else {
// Check if clicking on existing note — remove it
const existing = notes.findIndex(n =>
n.note === noteIdx && snappedBeat >= n.start && snappedBeat < n.start + n.duration
);
if (existing >= 0) {
const filtered = [...notes];
filtered.splice(existing, 1);
mod.params._notes = filtered;
notesRef.current = filtered;
emit();
return;
}
// Start drawing new note
drawingRef.current = { note: noteIdx, start: snappedBeat, duration: 0.25 };
const handleMove = (me) => {
if (!drawingRef.current) return;
const mmx = me.clientX - rect.left;
const endBeat = Math.max(drawingRef.current.start + 0.25,
Math.ceil(((mmx - KEY_W) / beatW) * 4) / 4);
drawingRef.current.duration = endBeat - drawingRef.current.start;
};
const handleUp = () => {
if (drawingRef.current) {
const newNote = { ...drawingRef.current };
newNote.duration = Math.max(0.25, Math.min(newNote.duration, totalBeats - newNote.start));
const updated = [...notes, newNote];
mod.params._notes = updated;
notesRef.current = updated;
drawingRef.current = null;
emit();
}
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
}
}, [tool, notes, beatW, totalBeats, mod]);
return (
<div style={{ width: ROLL_W }}>
{/* Mini toolbar */}
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
<button
style={{
padding: '1px 6px', fontSize: 9, border: '1px solid',
borderColor: tool === 'draw' ? '#00e5ff' : '#333',
background: tool === 'draw' ? '#00e5ff' : '#111',
color: tool === 'draw' ? '#000' : '#888',
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
}}
onClick={() => setTool('draw')}
> Draw</button>
<button
style={{
padding: '1px 6px', fontSize: 9, border: '1px solid',
borderColor: tool === 'erase' ? '#ff4466' : '#333',
background: tool === 'erase' ? '#ff4466' : '#111',
color: tool === 'erase' ? '#000' : '#888',
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
}}
onClick={() => setTool('erase')}
> Erase</button>
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
{notes.length} notes · {bars} bars
</span>
</div>
<canvas
ref={canvasRef}
width={ROLL_W}
height={ROLL_H}
style={{ width: ROLL_W, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
onPointerDown={handleMouseDown}
/>
</div>
);
}