"use client"; /** * Jam Buddy take visualizer. * * Renders the user's take and the buddy's response on a canvas: * - MIDI -> a piano-roll (notes as bars over time, pitch on the y-axis). * - audio -> a real waveform (min/max peaks over time). * * MIDI has no waveform (it's note data, not audio), so the honest visual for a * MIDI take is a piano-roll; audio gets the waveform. Both share a time axis so * you can compare the take and the response side by side. */ import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; import { parseMidi, type ParsedNote } from "./player"; export interface VisualizerHandle { /** Stop any audio this visualizer is playing (used to enforce exclusivity). */ stop: () => void; } interface VisualizerProps { /** MIDI bytes -> piano-roll. Mutually exclusive with audioUrl. */ midiBytes?: ArrayBuffer | null; /** Audio object URL -> waveform. Mutually exclusive with midiBytes. */ audioUrl?: string | null; /** Optional label shown above the canvas. */ label?: string; /** Height of the canvas in px. */ height?: number; /** Show a play/stop toggle on the waveform (audio only). */ playable?: boolean; /** Called right before this visualizer starts playing, so the owner can stop * any other audio source (enforces "one waveform, one playback"). */ onStartPlayback?: () => void; } const NOTE_MIN = 21; // A0 const NOTE_MAX = 108; // C8 /** Draw a piano-roll of MIDI notes onto a canvas. */ function drawPianoRoll( canvas: HTMLCanvasElement, notes: ParsedNote[], duration: number, ) { const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.fillStyle = "#12131b"; ctx.fillRect(0, 0, w, h); if (notes.length === 0 || duration <= 0) { ctx.fillStyle = "#7f829c"; ctx.font = "12px ui-monospace, monospace"; ctx.textAlign = "center"; ctx.fillText("no notes", w / 2, h / 2); return; } const pad = 8; const plotW = w - pad * 2; const plotH = h - pad * 2; const span = NOTE_MAX - NOTE_MIN; // Grid lines every octave. ctx.strokeStyle = "#2a2d3d"; ctx.lineWidth = 1; for (let n = NOTE_MIN; n <= NOTE_MAX; n += 12) { const y = pad + (1 - (n - NOTE_MIN) / span) * plotH; ctx.beginPath(); ctx.moveTo(pad, y); ctx.lineTo(w - pad, y); ctx.stroke(); } // Notes as bars. const maxTime = Math.max(duration, ...notes.map((n) => n.time + n.duration)); for (const n of notes) { const x = pad + (n.time / maxTime) * plotW; const bw = Math.max(2, (n.duration / maxTime) * plotW); const y = pad + (1 - (n.midi - NOTE_MIN) / span) * plotH; const bh = Math.max(2, plotH / span); ctx.fillStyle = n.channel === 9 ? "#f4a261" : "#5fd38a"; ctx.fillRect(x, y - bh, bw, bh); } } /** Draw a waveform (min/max peaks) of an AudioBuffer onto a canvas. */ function drawWaveform( canvas: HTMLCanvasElement, buffer: AudioBuffer, ) { const ctx = canvas.getContext("2d"); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = w * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.fillStyle = "#12131b"; ctx.fillRect(0, 0, w, h); const data = buffer.getChannelData(0); const step = Math.ceil(data.length / w); const amp = h / 2; ctx.fillStyle = "#5fd38a"; for (let x = 0; x < w; x++) { let min = 1; let max = -1; for (let i = 0; i < step; i++) { const v = data[x * step + i]; if (v === undefined) continue; if (v < min) min = v; if (v > max) max = v; } const y1 = amp + min * amp; const y2 = amp + max * amp; ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1)); } } export const Visualizer = forwardRef( function Visualizer( { midiBytes, audioUrl, label, height = 120, playable = false, onStartPlayback, }, ref, ) { const canvasRef = useRef(null); // Play/stop state for the waveform toggle (audio only). const [playing, setPlaying] = useState(false); const audioRef = useRef(null); // Expose a stop() so the parent can enforce one-waveform-at-a-time. useImperativeHandle(ref, () => ({ stop: () => { audioRef.current?.pause(); audioRef.current = null; setPlaying(false); }, })); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; // reset play state whenever the audio changes setPlaying(false); if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } if (midiBytes) { let notes: ParsedNote[] = []; let duration = 0; try { notes = parseMidi(midiBytes); duration = notes.reduce( (m, n) => Math.max(m, n.time + n.duration), 0, ); } catch { /* corrupt bytes -> empty roll */ } drawPianoRoll(canvas, notes, duration); return; } if (audioUrl) { const ctx = new AudioContext(); fetch(audioUrl) .then((r) => r.arrayBuffer()) .then((buf) => ctx.decodeAudioData(buf)) .then((audio) => { drawWaveform(canvas, audio); ctx.close(); }) .catch(() => { const c = canvas.getContext("2d"); if (c) { c.fillStyle = "#12131b"; c.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight); c.fillStyle = "#7f829c"; c.font = "12px ui-monospace, monospace"; c.textAlign = "center"; c.fillText("no audio", canvas.clientWidth / 2, canvas.clientHeight / 2); } }); return; } // Nothing to draw. const c = canvas.getContext("2d"); if (c) { c.fillStyle = "#12131b"; c.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight); } }, [midiBytes, audioUrl]); // Toggle play/stop of the waveform's audio (audio only). const togglePlay = () => { if (!audioUrl) return; if (playing) { audioRef.current?.pause(); audioRef.current = null; setPlaying(false); return; } // Tell the owner to stop anything else first (one playback at a time). onStartPlayback?.(); const a = new Audio(audioUrl); a.onended = () => { audioRef.current = null; setPlaying(false); }; audioRef.current = a; a.play() .then(() => setPlaying(true)) .catch(() => { audioRef.current = null; setPlaying(false); }); }; return (
{label && (
{label}
)}
{playable && audioUrl && ( )}
); }, );