Spaces:
No application file
No application file
| 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<HTMLCanvasElement | null>(null); | |
| const containerRef = useRef<HTMLDivElement | null>(null); | |
| const [zoom, setZoom] = useState<number>(60); // pixels per second | |
| const [synthEnabled, setSynthEnabled] = useState<boolean>(true); | |
| const [activeNotes, setActiveNotes] = useState<Set<number>>(new Set()); | |
| // Web Audio Context for real-time MIDI synthesizers | |
| const audioCtxRef = useRef<AudioContext | null>(null); | |
| const activeSynthsRef = useRef<Map<number, { osc: OscillatorNode; gain: GainNode }>>(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<number>(); | |
| 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<number>(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<HTMLCanvasElement>) => { | |
| 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 ( | |
| <div className="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-2xl transition-all duration-300"> | |
| {/* Header controls toolbar */} | |
| <div className="flex flex-wrap items-center justify-between border-b border-slate-800 bg-slate-950 px-4 py-3 gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <Music className="w-5 h-5 text-emerald-400" id="visualizer-header-icon" /> | |
| <h3 className="text-sm font-semibold tracking-wide text-slate-100 font-sans"> | |
| TRANSCRIPTION PIANO ROLL | |
| </h3> | |
| <span className="bg-emerald-950 text-emerald-400 text-xs px-2 py-0.5 rounded-full border border-emerald-800 font-mono"> | |
| {notes.length} notes | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| {/* Zoom controls */} | |
| <div className="flex items-center bg-slate-900 border border-slate-800 rounded-lg p-1 text-slate-400"> | |
| <button | |
| onClick={() => setZoom(Math.max(20, zoom - 10))} | |
| className="p-1 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors" | |
| title="Zoom Out" | |
| > | |
| <ZoomOut className="w-4 h-4" /> | |
| </button> | |
| <span className="text-xs px-2 font-mono">{zoom}px/s</span> | |
| <button | |
| onClick={() => setZoom(Math.min(150, zoom + 10))} | |
| className="p-1 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors" | |
| title="Zoom In" | |
| > | |
| <ZoomIn className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| {/* Synthesizer audio monitoring tool */} | |
| <button | |
| onClick={() => setSynthEnabled(!synthEnabled)} | |
| className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold tracking-normal border transition-all ${ | |
| synthEnabled | |
| ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/20" | |
| : "bg-slate-900 text-slate-400 border-slate-800 hover:bg-slate-800 hover:text-slate-300" | |
| }`} | |
| > | |
| {synthEnabled ? <Volume2 className="w-3.5 h-3.5" /> : <VolumeX className="w-3.5 h-3.5" />} | |
| INTERNAL SYNTH: {synthEnabled ? "ON" : "OFF"} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Main Split Piano Keys + Scrollable Canvas Grid */} | |
| <div className="flex relative h-80 select-none bg-slate-950"> | |
| {/* Left Vertical Axis: Piano Keys Guide */} | |
| <div | |
| className="w-16 flex-shrink-0 bg-slate-900 border-r border-slate-800 overflow-hidden relative z-10 select-none" | |
| style={{ height: "100%" }} | |
| > | |
| <div | |
| className="absolute left-0 w-full flex flex-col pt-0 transition-transform duration-75 select-none" | |
| style={{ | |
| height: `${canvasHeight}px`, | |
| bottom: 0 // Align keys to bottom grid matching midi pitch logic | |
| }} | |
| > | |
| {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 ( | |
| <div | |
| key={currentPitch} | |
| className={`flex items-center justify-between border-b border-slate-800/50 pr-2 font-mono text-[9px] select-none ${ | |
| isBlackKey | |
| ? "bg-slate-950 text-sky-400 font-semibold" | |
| : "bg-white text-slate-900" | |
| } ${isVoiceSounding ? "bg-amber-400 text-slate-950 border-amber-600 font-bold" : ""}`} | |
| style={{ | |
| height: `${heightPerNote}px`, | |
| // Reverse the vertical index to go top-to-bottom | |
| order: totalNotes - idx | |
| }} | |
| > | |
| <div className={`w-3.5 h-2 rounded-r select-none ${isBlackKey ? "bg-slate-800" : "bg-slate-200 border border-slate-300"}`} /> | |
| <span className="select-none">{getNoteName(currentPitch)}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* Scrollable Canvas for notes */} | |
| <div | |
| ref={containerRef} | |
| className="flex-grow overflow-x-auto overflow-y-hidden custom-scrollbar relative bg-slate-950" | |
| id="piano-roll-scroll-container" | |
| > | |
| {/* Virtual space corresponding to horizontal zoom */} | |
| <div | |
| style={{ | |
| width: `${Math.max(canvasWidth, (duration || 30) * zoom)}px`, | |
| height: "100%" | |
| }} | |
| className="relative" | |
| > | |
| <canvas | |
| ref={canvasRef} | |
| width={Math.max(canvasWidth, (duration || 30) * zoom)} | |
| height={canvasHeight} | |
| onClick={handleCanvasClick} | |
| className="absolute left-0 bottom-0 cursor-crosshair h-full" | |
| style={{ maxHeight: "100%" }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Control bar / timing guide */} | |
| <div className="bg-slate-950 border-t border-slate-800 px-4 py-2 flex flex-wrap items-center justify-between text-xs text-slate-400 gap-2"> | |
| <div className="flex items-center gap-4 font-mono"> | |
| <div> | |
| TIME: <span className="text-slate-200">{currentTime.toFixed(2)}s</span> / <span className="text-slate-400">{(duration || 0).toFixed(2)}s</span> | |
| </div> | |
| <div className="hidden sm:block"> | |
| BEATS: <span className="text-slate-200">{Math.floor((currentTime / 60) * 120)}</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 bg-slate-900 px-3 py-1 rounded border border-slate-800 font-sans"> | |
| <span className="inline-block w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> | |
| <span>Interactive Playback Active: Click on grid to seek</span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |