stemstemstem / MidiVisualizer.tsx
Ryanrealaf's picture
Upload 4 files
54f863b verified
Raw
History Blame Contribute Delete
16.4 kB
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>
);
}