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; } /** * `` — 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(null); const titleId = useId(); const [decoded, setDecoded] = useState(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(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(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) => { if (!onSeek) return; const rect = e.currentTarget.getBoundingClientRect(); onSeek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); }; return (
); } /** 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(bars).fill(0); const out = new Array(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; }