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 (