jam-buddy / apps /web /lib /jambuddy /visualizer.tsx
salgadev's picture
Sync from GitHub af6d0631
44948ab verified
Raw
History Blame Contribute Delete
8.06 kB
"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<VisualizerHandle, VisualizerProps>(
function Visualizer(
{
midiBytes,
audioUrl,
label,
height = 120,
playable = false,
onStartPlayback,
},
ref,
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// Play/stop state for the waveform toggle (audio only).
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(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 (
<div className="w-full">
{label && (
<div className="mb-1 font-mono text-[10px] uppercase tracking-widest text-[#7f829c]">
{label}
</div>
)}
<div className="flex items-center gap-2">
{playable && audioUrl && (
<button
type="button"
onClick={togglePlay}
aria-label={playing ? "Stop playback" : "Play"}
title={playing ? "Stop" : "Play"}
className={`grid h-9 w-9 shrink-0 place-items-center rounded-full border text-sm ${
playing
? "border-[#e05252] bg-[#e05252] text-[#12131b]"
: "border-[#2a2d3d] bg-[#1a1c28] text-[#5fd38a] hover:bg-[#5fd38a] hover:text-[#12131b]"
}`}
>
{playing ? "■" : "▶"}
</button>
)}
<canvas
ref={canvasRef}
className="w-full rounded border border-[#2a2d3d] bg-[#12131b]"
style={{ height }}
/>
</div>
</div>
);
},
);