Files
reaktor/src/components/PianoRollWidget.jsx
Jose Luis fce0bcdace fix: dynamic sizing for sequencer and piano roll modules
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>
2026-03-21 04:48:27 +01:00

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