Module width now adapts to step/bar count so extra steps are never hidden. Sequencer width scales with numSteps, piano roll width scales with bar count using a fixed BEAT_PX density. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
465 lines
16 KiB
JavaScript
465 lines
16 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 } from '../engine/audioEngine.js';
|
|
import { parseMidi } from '../utils/midiParser.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); }
|
|
|
|
// Super Mario Bros - Overworld Theme (NES, 1985)
|
|
// BPM ~200, swing 8ths feel. Each beat = 1 quarter note.
|
|
// s = eighth note unit (0.5 beats)
|
|
const s = 0.5;
|
|
const MARIO_MELODY = [
|
|
// Bar 1: E5 E5 . E5 . C5 E5 .
|
|
{ note: 76, start: 0, duration: s }, // E5
|
|
{ note: 76, start: 1*s, duration: s }, // E5
|
|
// rest
|
|
{ note: 76, start: 3*s, duration: s }, // E5
|
|
// rest
|
|
{ note: 72, start: 5*s, duration: s }, // C5
|
|
{ note: 76, start: 6*s, duration: 2*s }, // E5
|
|
|
|
// Bar 2: G5 . . . G4 . . .
|
|
{ note: 79, start: 8*s, duration: 2*s }, // G5
|
|
{ note: 67, start: 12*s, duration: 2*s }, // G4
|
|
|
|
// Bar 3: C5 . . G4 . . E4 . .
|
|
{ note: 72, start: 16*s, duration: s }, // C5
|
|
{ note: 67, start: 18*s, duration: s }, // G4
|
|
{ note: 64, start: 20*s, duration: s }, // E4
|
|
|
|
// Bar 4: . A4 . B4 . Bb4 A4 .
|
|
{ note: 69, start: 22*s, duration: s }, // A4
|
|
{ note: 71, start: 24*s, duration: s }, // B4
|
|
{ note: 70, start: 25*s, duration: s }, // Bb4
|
|
{ note: 69, start: 26*s, duration: s }, // A4
|
|
|
|
// Bar 5: G4 E5 G5 A5 . F5 G5 .
|
|
{ note: 67, start: 28*s, duration: 0.75 }, // G4 (triplet feel)
|
|
{ note: 76, start: 30*s, duration: 0.75 }, // E5
|
|
{ note: 79, start: 32*s, duration: s }, // G5
|
|
{ note: 81, start: 33*s, duration: s }, // A5
|
|
{ note: 77, start: 35*s, duration: s }, // F5
|
|
{ note: 79, start: 36*s, duration: s }, // G5
|
|
|
|
// Bar 6: . E5 . C5 D5 B4 . .
|
|
{ note: 76, start: 38*s, duration: s }, // E5
|
|
{ note: 72, start: 40*s, duration: s }, // C5
|
|
{ note: 74, start: 41*s, duration: s }, // D5
|
|
{ note: 71, start: 42*s, duration: s }, // B4
|
|
|
|
// Bar 7: . . C5 . . G4 . . E4
|
|
{ note: 72, start: 44*s, duration: s }, // C5
|
|
{ note: 67, start: 46*s, duration: s }, // G4
|
|
{ note: 64, start: 48*s, duration: s }, // E4
|
|
|
|
// Bar 8: . A4 . B4 . Bb4 A4 .
|
|
{ note: 69, start: 50*s, duration: s }, // A4
|
|
{ note: 71, start: 52*s, duration: s }, // B4
|
|
{ note: 70, start: 53*s, duration: s }, // Bb4
|
|
{ note: 69, start: 54*s, duration: s }, // A4
|
|
|
|
// Bar 9: G4 E5 G5 A5 . F5 G5 .
|
|
{ note: 67, start: 56*s, duration: 0.75 }, // G4
|
|
{ note: 76, start: 58*s, duration: 0.75 }, // E5
|
|
{ note: 79, start: 60*s, duration: s }, // G5
|
|
{ note: 81, start: 61*s, duration: s }, // A5
|
|
{ note: 77, start: 63*s, duration: s }, // F5
|
|
{ note: 79, start: 64*s, duration: s }, // G5
|
|
|
|
// Bar 10: . E5 . C5 D5 B4
|
|
{ note: 76, start: 66*s, duration: s }, // E5
|
|
{ note: 72, start: 68*s, duration: s }, // C5
|
|
{ note: 74, start: 69*s, duration: s }, // D5
|
|
{ note: 71, start: 70*s, duration: 2*s }, // B4
|
|
];
|
|
|
|
const BEAT_PX = 30; // pixels per beat — constant density regardless of bar count
|
|
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 midiInputRef = 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 = [...MARIO_MELODY];
|
|
}
|
|
const notes = mod?.params?._notes || MARIO_MELODY;
|
|
const notesRef = useRef(notes);
|
|
notesRef.current = notes;
|
|
|
|
const rollW = KEY_W + totalBeats * BEAT_PX;
|
|
const beatW = BEAT_PX;
|
|
|
|
// 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, rollW]);
|
|
|
|
// Animation loop
|
|
useEffect(() => {
|
|
const animate = () => {
|
|
draw();
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
};
|
|
animate();
|
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
|
}, [draw]);
|
|
|
|
// Playback — uses independent Tone.Clock so multiple pianorolls/sequencers
|
|
// don't interfere with each other via the global Transport
|
|
useEffect(() => {
|
|
if (!state.isRunning) {
|
|
if (partRef.current) {
|
|
try { partRef.current.stop(); } catch {}
|
|
try { partRef.current.dispose(); } catch {}
|
|
partRef.current = null;
|
|
}
|
|
setPlayPos(-1);
|
|
return;
|
|
}
|
|
|
|
const sixteenthRate = (bpm * 4) / 60; // Hz: sixteenth notes per second
|
|
const sixteenthDur = 1 / sixteenthRate; // seconds per sixteenth note
|
|
let tickCount = 0;
|
|
let currentNote = null; // track currently sounding note for on/off transitions
|
|
|
|
const clock = new Tone.Clock(() => {
|
|
const rawPos = tickCount * 0.25; // position in beats (each tick = 1 sixteenth = 0.25 beats)
|
|
const pos = loop ? rawPos % totalBeats : rawPos;
|
|
const prevRawPos = (tickCount - 1) * 0.25;
|
|
const prevPos = loop ? prevRawPos % totalBeats : prevRawPos;
|
|
tickCount++;
|
|
|
|
// Detect loop wrap (position jumped backwards)
|
|
const looped = tickCount > 1 && pos < prevPos;
|
|
|
|
// Stop at end if not looping
|
|
if (!loop && rawPos >= totalBeats) {
|
|
if (currentNote) {
|
|
setSequencerSignals(moduleId, 0, false);
|
|
currentNote = null;
|
|
}
|
|
setPlayPos(-1);
|
|
return;
|
|
}
|
|
|
|
setPlayPos(pos);
|
|
|
|
// Force note-off on loop boundary for clean retrigger
|
|
if (looped && currentNote) {
|
|
setSequencerSignals(moduleId, 0, false);
|
|
currentNote = null;
|
|
}
|
|
|
|
// Find the note active at this position
|
|
const allNotes = notesRef.current;
|
|
const activeNote = allNotes.find(n => pos >= n.start && pos < n.start + n.duration);
|
|
|
|
if (activeNote) {
|
|
// New note or different note → trigger
|
|
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
|
|
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
|
|
currentNote = activeNote;
|
|
}
|
|
// Same note sustaining → do nothing
|
|
} else {
|
|
// No note at this position → gate off
|
|
if (currentNote) {
|
|
setSequencerSignals(moduleId, 0, false);
|
|
currentNote = null;
|
|
}
|
|
}
|
|
}, sixteenthRate);
|
|
|
|
clock.start();
|
|
partRef.current = clock;
|
|
|
|
return () => {
|
|
if (partRef.current) {
|
|
try { partRef.current.stop(); } catch {}
|
|
try { partRef.current.dispose(); } catch {}
|
|
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();
|
|
// Account for CSS transform scale (zoom) — rect is visual size, canvas is logical size
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
const mx = (e.clientX - rect.left) * scaleX;
|
|
const my = (e.clientY - rect.top) * scaleY;
|
|
|
|
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) * scaleX;
|
|
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]);
|
|
|
|
const handleMidiImport = useCallback(async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const midi = parseMidi(arrayBuffer);
|
|
if (midi.notes.length === 0) return;
|
|
|
|
// Update BPM if detected
|
|
if (midi.bpm && mod) {
|
|
mod.params.bpm = midi.bpm;
|
|
}
|
|
|
|
// Auto-fit bars to cover all notes
|
|
const maxBeat = Math.max(...midi.notes.map(n => n.start + n.duration));
|
|
const neededBars = Math.ceil(maxBeat / 4);
|
|
const fitBars = [1, 2, 4, 8].find(b => b >= neededBars) || 8;
|
|
if (mod) mod.params.bars = String(fitBars);
|
|
|
|
// Set notes
|
|
if (mod) {
|
|
mod.params._notes = midi.notes;
|
|
notesRef.current = midi.notes;
|
|
emit();
|
|
}
|
|
} catch (err) {
|
|
console.error('[PianoRoll] MIDI import failed:', err);
|
|
}
|
|
e.target.value = '';
|
|
}, [mod]);
|
|
|
|
return (
|
|
<div style={{ width: rollW }}>
|
|
{/* 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>
|
|
<button
|
|
style={{
|
|
padding: '1px 6px', fontSize: 9, border: '1px solid #333',
|
|
background: '#111', color: '#888',
|
|
borderRadius: 3, cursor: 'pointer', fontFamily: 'inherit',
|
|
}}
|
|
onClick={() => midiInputRef.current?.click()}
|
|
>🎵 MIDI</button>
|
|
<input ref={midiInputRef} type="file" accept=".mid,.midi" style={{ display: 'none' }} onChange={handleMidiImport} />
|
|
<span style={{ fontSize: 8, color: '#555', marginLeft: 'auto', alignSelf: 'center' }}>
|
|
{notes.length} notes · {bars} bars
|
|
</span>
|
|
</div>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={rollW}
|
|
height={ROLL_H}
|
|
style={{ width: rollW, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
|
|
onPointerDown={handleMouseDown}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|