learning_ai_common_plat/packages/media-ui/src/AudioWaveform.tsx
saravanakumardb1 99e59597d1 feat(media-ui): @bytelyst/media-ui@0.1.0 — Wave 13.G.1/.2/.4
Media surface primitives — ImageGenStream, AudioWaveform, VideoPlayer.
Zero runtime deps (canvas + native <video> + native <img>); PdfPreview
deferred to 0.2.x where it'll lazy-load pdf.js.

──────────────────────────────────────────────────────────────────
<ImageGenStream>  ·  Wave 13.G.1
──────────────────────────────────────────────────────────────────
  - Driven entirely by props (status + snapshot + finalUrl)
  - 4 statuses: idle / streaming / complete / error
  - Streaming: blurred partial image + overlay progress bar +
    step label (e.g. 'denoising 24/50')
  - Complete: drops blur with fade-in
  - Error: muted error placeholder (token-tinted danger)
  - Host pipes any transport (SSE / WebSocket / polling) into
    the snapshot prop — pure presentation

<AudioWaveform>  ·  Wave 13.G.2
  - Canvas render with DPR-aware paint
  - Two sources: pre-computed peaks (cheapest) OR lazy WebAudio
    decode from audioUrl (falls back gracefully w/o AudioContext)
  - Click-to-seek (returns 0..1 position)
  - Progress overlay tints played bars with progressColor
  - Resampling helper handles arbitrary peak-count inputs

<VideoPlayer>  ·  Wave 13.G.4
  - Native <video controls> wrapper — accessible by default
  - Chapter buttons that seek + auto-play (silently swallows
    autoplay rejections)
  - Optional in-memory caption track (aria-live polite); hosts
    that prefer real WebVTT pass <track> via the slot prop
  - Token-tinted; rounded; muted poster background

──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
  ✓ 10/10 tests passing
    - AudioWaveform: 3 cases (canvas size · click-seek math ·
      placeholder when no peaks)
    - ImageGenStream: 4 cases (idle / streaming / complete /
      error)
    - VideoPlayer: 3 cases (src · chapter button list · caption
      rail)
  ✓ tsc build clean
  ✓ Zero new runtime deps; all WebAudio access guarded for SSR

Showcase /futurism/multimodal (MAG.7) lands in the paired showcase
commit.
2026-05-27 17:44:13 -07:00

181 lines
5.5 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 = 96,
progress = 0,
onSeek,
width = 480,
height = 80,
color = 'var(--bl-accent, #6366f1)',
progressColor = 'var(--bl-text-primary, #111)',
ariaLabel,
className,
style,
}: AudioWaveformProps) {
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();
const audio = await ac.decodeAudioData(buf);
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;
}