import React, { useRef, useEffect, useState } from "react"; import { Play, Pause, Square, Music, Volume2, VolumeX, Maximize2, ZoomIn, ZoomOut } from "lucide-react"; import { NoteEvent } from "../utils/midiWriter"; interface MidiVisualizerProps { notes: NoteEvent[]; currentTime: number; duration: number; onScrub: (seconds: number) => void; isPlaying: boolean; onPlayToggle: () => void; } export default function MidiVisualizer({ notes, currentTime, duration, onScrub, isPlaying, onPlayToggle, }: MidiVisualizerProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const [zoom, setZoom] = useState(60); // pixels per second const [synthEnabled, setSynthEnabled] = useState(true); const [activeNotes, setActiveNotes] = useState>(new Set()); // Web Audio Context for real-time MIDI synthesizers const audioCtxRef = useRef(null); const activeSynthsRef = useRef>(new Map()); // Set up synth Web Audio context const getAudioContext = () => { if (!audioCtxRef.current) { audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); } if (audioCtxRef.current.state === "suspended") { audioCtxRef.current.resume(); } return audioCtxRef.current; }; // Convert MIDI note to Frequency const mtoF = (note: number) => { return 440 * Math.pow(2, (note - 69) / 12); }; // Trigger synth note on const playSynthNote = (noteNumber: number, velocity: number) => { if (!synthEnabled) return; try { const ctx = getAudioContext(); // If note already playing, stop it first stopSynthNote(noteNumber); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; // Nice warm, quasi-piano/organ sound osc.frequency.value = mtoF(noteNumber); // Simple ADSR envelope with fast decay to simulate piano keys const now = ctx.currentTime; const vol = (velocity / 127) * 0.25; // Scale down safely to avoid shearing gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(vol, now + 0.02); // attack gain.gain.exponentialRampToValueAtTime(vol * 0.3, now + 0.4); // decay to sustain osc.connect(gain); gain.connect(ctx.destination); osc.start(now); activeSynthsRef.current.set(noteNumber, { osc, gain }); } catch (e) { console.warn("Synth voice start error", e); } }; // Stop synth voice const stopSynthNote = (noteNumber: number) => { try { const voice = activeSynthsRef.current.get(noteNumber); if (voice) { const ctx = getAudioContext(); const now = ctx.currentTime; // Fast release to avoid click voice.gain.gain.cancelScheduledValues(now); voice.gain.gain.setValueAtTime(voice.gain.gain.value, now); voice.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.15); setTimeout(() => { try { voice.osc.stop(); voice.osc.disconnect(); voice.gain.disconnect(); } catch (err) {} }, 200); activeSynthsRef.current.delete(noteNumber); } } catch (e) { console.warn("Synth voice release error", e); } }; // Mute synth if disabled useEffect(() => { if (!synthEnabled) { // stop all playing notes immediately activeSynthsRef.current.forEach((_, note) => stopSynthNote(note)); activeSynthsRef.current.clear(); } }, [synthEnabled]); // Track and play keyboard synths based on real-time playhead useEffect(() => { if (!isPlaying) { // stop all synthesisers when paused activeSynthsRef.current.forEach((_, note) => stopSynthNote(note)); activeSynthsRef.current.clear(); setActiveNotes(new Set()); return; } const currentPlayingNotes = new Set(); notes.forEach((note) => { const isNoteActive = currentTime >= note.onset_time && currentTime <= note.offset_time; if (isNoteActive) { currentPlayingNotes.add(note.midi_note); } }); // For any notes starting now currentPlayingNotes.forEach((noteNum) => { if (!activeNotes.has(noteNum)) { // find note object for velocity const noteObj = notes.find((n) => n.midi_note === noteNum && currentTime >= n.onset_time && currentTime <= n.offset_time); playSynthNote(noteNum, noteObj ? noteObj.velocity : 80); } }); // For any notes that just ended activeNotes.forEach((noteNum) => { if (!currentPlayingNotes.has(noteNum)) { stopSynthNote(noteNum); } }); setActiveNotes(currentPlayingNotes); }, [currentTime, isPlaying, notes]); // Clean up synthesisers on unmount useEffect(() => { return () => { activeSynthsRef.current.forEach(({ osc, gain }) => { try { osc.stop(); osc.disconnect(); gain.disconnect(); } catch (e) {} }); activeSynthsRef.current.clear(); }; }, []); // Determine key range for drawing boundaries const pitchRange = (() => { if (notes.length === 0) return { min: 48, max: 72 }; // default 2 octaves const pitches = notes.map((n) => n.midi_note); return { min: Math.max(0, Math.min(...pitches) - 2), max: Math.min(127, Math.max(...pitches) + 2), }; })(); const heightPerNote = 18; // Height in pixels for each piano key row const totalNotes = pitchRange.max - pitchRange.min + 1; const canvasHeight = totalNotes * heightPerNote; // Track resize const [canvasWidth, setCanvasWidth] = useState(800); useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { if (entries[0]) { setCanvasWidth(entries[0].contentRect.width); } }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); // Canvas Drawing useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // Clear background ctx.fillStyle = "#0f172a"; // Deep Slate base ctx.fillRect(0, 0, canvas.width, canvas.height); // Dynamic horizontal scale const horizontalScale = zoom; // pixels per second const visibleTimeStart = 0; // standard container left coordinate has offset // Draw grid lines ctx.strokeStyle = "#1e293b"; ctx.lineWidth = 1; const gridDuration = duration || 30; // Draw horizontal row dividing lines for (let i = 0; i <= totalNotes; i++) { const y = canvasHeight - i * heightPerNote; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); // Check if this row is a black key on a piano const currentPitch = pitchRange.min + i; const keyInOctave = currentPitch % 12; const isBlackKey = [1, 3, 6, 8, 10].includes(keyInOctave); if (isBlackKey && i < totalNotes) { ctx.fillStyle = "rgba(15, 23, 42, 0.45)"; ctx.fillRect(0, canvasHeight - (i + 1) * heightPerNote, canvas.width, heightPerNote); } } // Draw vertical beat separators (grid ticks) const timeStep = 0.5; // lines every 0.5s for (let t = 0; t <= gridDuration; t += timeStep) { const x = t * horizontalScale; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); // emphasize beats vs subdivisions ctx.strokeStyle = t % 1 === 0 ? "#334155" : "#1e293b"; ctx.stroke(); } // Draw transcribed MIDI Note Blocks notes.forEach((note) => { const noteX = note.onset_time * horizontalScale; const noteW = (note.offset_time - note.onset_time) * horizontalScale; const pitchIndex = note.midi_note - pitchRange.min; const noteY = canvasHeight - (pitchIndex + 1) * heightPerNote; const isVoiceActive = currentTime >= note.onset_time && currentTime <= note.offset_time; // Color based on velocity + active state if (isVoiceActive) { // Highlighting active sounding notes ctx.fillStyle = "#34d399"; // glowing emerald ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; } else { // Tone color based on velocity const percentage = Math.min(100, Math.max(20, (note.velocity / 127) * 100)); ctx.fillStyle = `hsla(199, 89%, ${percentage * 0.6 + 25}%, 0.85)`; // nice sky blue notes ctx.strokeStyle = "rgba(56, 189, 248, 0.4)"; ctx.lineWidth = 1; } // Draw rounded rectangle for note block ctx.beginPath(); ctx.roundRect(noteX + 1, noteY + 1, Math.max(2, noteW - 2), heightPerNote - 2, 3); ctx.fill(); ctx.stroke(); }); // Draw running playback head const playheadX = currentTime * horizontalScale; ctx.strokeStyle = "#ef4444"; // bright red head ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(playheadX, 0); ctx.lineTo(playheadX, canvas.height); ctx.stroke(); // Small glowing circle at top of playhead ctx.fillStyle = "#ef4444"; ctx.beginPath(); ctx.arc(playheadX, 4, 4, 0, Math.PI * 2); ctx.fill(); }, [notes, currentTime, zoom, duration, pitchRange, canvasHeight, totalNotes]); // Click on canvas to scrub timeline const handleCanvasClick = (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickedSeconds = clickX / zoom; onScrub(Math.min(duration || 0, Math.max(0, clickedSeconds))); }; // Convert MIDI note number to human pitch string (C4, D#2, etc) const getNoteName = (midiNum: number) => { const notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; const octave = Math.floor(midiNum / 12) - 1; const noteName = notes[midiNum % 12]; return `${noteName}${octave}`; }; return (
{/* Header controls toolbar */}

TRANSCRIPTION PIANO ROLL

{notes.length} notes
{/* Zoom controls */}
{zoom}px/s
{/* Synthesizer audio monitoring tool */}
{/* Main Split Piano Keys + Scrollable Canvas Grid */}
{/* Left Vertical Axis: Piano Keys Guide */}
{Array.from({ length: totalNotes }).map((_, idx) => { const currentPitch = pitchRange.min + idx; const keyInOctave = currentPitch % 12; const isBlackKey = [1, 3, 6, 8, 10].includes(keyInOctave); const isVoiceSounding = activeNotes.has(currentPitch); return (
{getNoteName(currentPitch)}
); })}
{/* Scrollable Canvas for notes */}
{/* Virtual space corresponding to horizontal zoom */}
{/* Control bar / timing guide */}
TIME: {currentTime.toFixed(2)}s / {(duration || 0).toFixed(2)}s
BEATS: {Math.floor((currentTime / 60) * 120)}
Interactive Playback Active: Click on grid to seek
); }