learning_ai_common_plat/packages/media-ui/src/AudioWaveform.tsx
saravanakumardb1 b2e45380ec fix(media-ui): plug AudioContext leak + guard <img> against undefined src
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}>
2026-05-27 18:44:50 -07:00

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;
}