Two latent bugs caught in the audit pass:
1. **AudioContext leak in AudioWaveform.**
The lazy WebAudio decoder constructed an `AudioContext` per
mount-with-audioUrl and never called `.close()`. Browsers cap the
total per page (Chrome ~6, Firefox ~6) so a long-lived session that
remounted waveforms enough times eventually hit
`InvalidStateError: cannot create AudioContext` and silently
stopped decoding.
Fix: wrap `decodeAudioData` in try/finally and `close()` the
context in the finally. Errors from close() are swallowed (best-
effort cleanup).
2. **Undefined img src in ImageGenStream.**
When `status=streaming` arrived before the first
`snapshot.partialUrl` (very common during the SSE handshake),
the component rendered <img src={undefined}> which browsers treat
as a broken-image icon. Same for status=complete + missing
finalUrl.
Fix: compute `visibleSrc` once; only mount <img> if a source
exists, otherwise show 'Waiting for first frame\u2026' placeholder.
Also removed the dead `revealedAt` state \u2014 the prior 0.95/1
opacity dance was imperceptible and contributed nothing.
3. **Bonus: AudioWaveform `bars` clamp.**
`bars={0}` (or negative / fractional) divided by zero on the
canvas slot math and rendered an empty waveform. Now clamped to
`Math.max(1, Math.floor(barsProp))`.
Tests: 10 \u2192 12
AudioWaveform \u00b7 bars=0 doesn't crash + canvas still renders
ImageGenStream \u00b7 streaming without partialUrl shows placeholder
instead of <img src={undefined}>
190 lines
5.8 KiB
TypeScript
190 lines
5.8 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type MouseEvent as ReactMouseEvent,
|
|
} from 'react';
|
|
|
|
export interface AudioWaveformProps {
|
|
/** Pre-computed peak samples (0..1). Mutually exclusive with `audioUrl`. */
|
|
peaks?: number[];
|
|
/** Audio file URL — when set + peaks absent, peaks are derived via WebAudio. */
|
|
audioUrl?: string;
|
|
/** Number of bars to render. Default 96. */
|
|
bars?: number;
|
|
/** Current playback position (0..1) — drives the progress overlay. */
|
|
progress?: number;
|
|
/** Called when the user clicks a bar (returns 0..1 position). */
|
|
onSeek?: (position: number) => void;
|
|
width?: number;
|
|
height?: number;
|
|
color?: string;
|
|
progressColor?: string;
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<AudioWaveform>` — canvas-rendered waveform with click-to-seek.
|
|
*
|
|
* Wave 13.G.2. Accepts a pre-computed `peaks` array (cheapest) OR
|
|
* lazy-decodes peaks via WebAudio for an `audioUrl`. While the decode
|
|
* is in flight (or in environments without `AudioContext`) a flat
|
|
* placeholder is rendered.
|
|
*/
|
|
export function AudioWaveform({
|
|
peaks: peaksProp,
|
|
audioUrl,
|
|
bars: barsProp = 96,
|
|
progress = 0,
|
|
onSeek,
|
|
width = 480,
|
|
height = 80,
|
|
color = 'var(--bl-accent, #6366f1)',
|
|
progressColor = 'var(--bl-text-primary, #111)',
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: AudioWaveformProps) {
|
|
// Clamp to a sane minimum — zero or negative bars would divide-by-zero
|
|
// on canvas and produce an empty render.
|
|
const bars = Math.max(1, Math.floor(barsProp));
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const titleId = useId();
|
|
const [decoded, setDecoded] = useState<number[] | null>(null);
|
|
|
|
// Lazy WebAudio decode when only audioUrl is supplied.
|
|
useEffect(() => {
|
|
if (peaksProp || !audioUrl) return;
|
|
if (typeof window === 'undefined') return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(audioUrl);
|
|
const buf = await res.arrayBuffer();
|
|
const Ctx =
|
|
(
|
|
window as unknown as {
|
|
AudioContext?: typeof AudioContext;
|
|
webkitAudioContext?: typeof AudioContext;
|
|
}
|
|
).AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
|
.webkitAudioContext;
|
|
if (!Ctx) return;
|
|
const ac = new Ctx();
|
|
let audio;
|
|
try {
|
|
audio = await ac.decodeAudioData(buf);
|
|
} finally {
|
|
// Free the AudioContext slot — browsers cap the count per page.
|
|
void ac.close?.().catch(() => {});
|
|
}
|
|
if (cancelled) return;
|
|
const channel = audio.getChannelData(0);
|
|
const block = Math.max(1, Math.floor(channel.length / bars));
|
|
const out = new Array<number>(bars).fill(0);
|
|
for (let i = 0; i < bars; i++) {
|
|
const start = i * block;
|
|
let max = 0;
|
|
for (let j = 0; j < block && start + j < channel.length; j++) {
|
|
const v = Math.abs(channel[start + j] ?? 0);
|
|
if (v > max) max = v;
|
|
}
|
|
out[i] = Math.min(1, max);
|
|
}
|
|
if (!cancelled) setDecoded(out);
|
|
} catch {
|
|
/* swallow — placeholder remains */
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [peaksProp, audioUrl, bars]);
|
|
|
|
const finalPeaks = useMemo(() => {
|
|
if (peaksProp && peaksProp.length > 0) return resampleTo(peaksProp, bars);
|
|
if (decoded && decoded.length > 0) return decoded;
|
|
return new Array<number>(bars).fill(0.04);
|
|
}, [peaksProp, decoded, bars]);
|
|
|
|
// Paint.
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const dpr =
|
|
typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
canvas.width = Math.floor(width * dpr);
|
|
canvas.height = Math.floor(height * dpr);
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, width, height);
|
|
const slot = width / finalPeaks.length;
|
|
const barW = Math.max(1, slot * 0.65);
|
|
const mid = height / 2;
|
|
const progressX = Math.max(0, Math.min(1, progress)) * width;
|
|
finalPeaks.forEach((p, i) => {
|
|
const x = slot * i + (slot - barW) / 2;
|
|
const h = Math.max(2, p * height * 0.9);
|
|
ctx.fillStyle = x + barW <= progressX ? progressColor : color;
|
|
ctx.fillRect(x, mid - h / 2, barW, h);
|
|
});
|
|
}, [finalPeaks, width, height, color, progressColor, progress]);
|
|
|
|
const onClick = (e: ReactMouseEvent<HTMLCanvasElement>) => {
|
|
if (!onSeek) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
onSeek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="img"
|
|
aria-labelledby={titleId}
|
|
data-testid="bl-audio-waveform"
|
|
className={className}
|
|
style={{ display: 'inline-block', ...style }}
|
|
>
|
|
<span id={titleId} hidden>
|
|
{ariaLabel ?? 'Audio waveform'}
|
|
</span>
|
|
<canvas
|
|
ref={canvasRef}
|
|
onClick={onClick}
|
|
style={{
|
|
width,
|
|
height,
|
|
cursor: onSeek ? 'pointer' : 'default',
|
|
display: 'block',
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Resample any peaks array up/down to `bars` length. */
|
|
function resampleTo(src: number[], bars: number): number[] {
|
|
if (src.length === bars) return src;
|
|
if (src.length === 0) return new Array<number>(bars).fill(0);
|
|
const out = new Array<number>(bars);
|
|
const ratio = src.length / bars;
|
|
for (let i = 0; i < bars; i++) {
|
|
const start = Math.floor(i * ratio);
|
|
const end = Math.max(start + 1, Math.floor((i + 1) * ratio));
|
|
let max = 0;
|
|
for (let j = start; j < end && j < src.length; j++) {
|
|
const v = Math.abs(src[j] ?? 0);
|
|
if (v > max) max = v;
|
|
}
|
|
out[i] = Math.min(1, max);
|
|
}
|
|
return out;
|
|
}
|