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:
361
src/components/PianoRollWidget.jsx
Normal file
361
src/components/PianoRollWidget.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user