import { memo, useRef, useState, useCallback, useEffect } from "react"; interface AudioWaveformProps { audioUrl: string; waveformUrl?: string; label: string; labelColor: string; /** * Fraction (0–1) of the source the clip starts at, after the media-start * trim. Defaults to 0 (no front trim). */ trimStartFraction?: number; /** * Fraction (0–1) of the source the clip ends at. Defaults to 1 (no tail * trim). Together these window the rendered peaks to the trimmed slice so the * waveform tracks the clip edges instead of squeezing the whole file in. */ trimEndFraction?: number; } const BAR_W = 2; const GAP = 1; const STEP = BAR_W + GAP; /** Downsample PCM channel data into peak amplitudes (0–1). */ function extractPeaks(channelData: Float32Array, barCount: number): number[] { const peaks: number[] = []; const samplesPerBar = Math.floor(channelData.length / barCount); if (samplesPerBar === 0) return Array(barCount).fill(0); for (let i = 0; i < barCount; i++) { let max = 0; const start = i * samplesPerBar; const end = Math.min(start + samplesPerBar, channelData.length); for (let j = start; j < end; j++) { const abs = Math.abs(channelData[j] ?? 0); if (abs > max) max = abs; } peaks.push(max); } const maxPeak = Math.max(...peaks, 0.001); return peaks.map((p) => p / maxPeak); } /** Deterministic fake waveform as fallback (matches demo app). */ function fakePeaks(url: string, count: number): number[] { let seed = 0; for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0; seed = Math.abs(seed) || 42; const rand = () => { seed = (seed * 16807) % 2147483647; return (seed & 0x7fffffff) / 2147483647; }; const peaks: number[] = []; for (let i = 0; i < count; i++) { const t = i / count; const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1); peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand())))); } return peaks; } // Module-level cache so decoded audio persists across re-renders and re-mounts const peaksCache = new Map(); const decodeInFlight = new Map>(); /** * Audio waveform rendered from real PCM data via Web Audio API. * Falls back to a deterministic fake pattern if decoding fails. * Bars grow from bottom to top, rendered as CSS divs for zoom resilience. */ export const AudioWaveform = memo(function AudioWaveform({ audioUrl, waveformUrl, label, labelColor, trimStartFraction, trimEndFraction, }: AudioWaveformProps) { const containerRef = useRef(null); const barsRef = useRef(null); const roRef = useRef(null); const cacheKey = waveformUrl ?? audioUrl; const [peaks, setPeaks] = useState(peaksCache.get(cacheKey) ?? null); useEffect(() => { if (peaks || !cacheKey) return; let cancelled = false; let promise = decodeInFlight.get(cacheKey); if (!promise) { promise = ( waveformUrl ? fetch(waveformUrl) .then((r) => r.json()) .then((d: { peaks?: number[] }) => { if (!Array.isArray(d.peaks)) throw new Error("bad response"); return d.peaks; }) : fetch(audioUrl) .then((r) => r.arrayBuffer()) .then((buf) => { const ctx = new AudioContext(); return ctx.decodeAudioData(buf).finally(() => ctx.close()); }) .then((decoded) => extractPeaks(decoded.getChannelData(0), 4000)) ) .catch(() => fakePeaks(cacheKey, 4000)) .then((p) => { peaksCache.set(cacheKey, p); return p; }) .finally(() => decodeInFlight.delete(cacheKey)); decodeInFlight.set(cacheKey, promise); } promise.then((p) => { if (!cancelled) setPeaks(p); }); return () => { cancelled = true; }; }, [audioUrl, waveformUrl, cacheKey, peaks]); // Draw bars into the container using innerHTML (fast, zoom-resilient) const draw = useCallback(() => { const container = containerRef.current; const barsEl = barsRef.current; if (!container || !barsEl || !peaks) return; // Window the peaks to the trimmed slice [start, end) of the source so the // bars track the clip edges. Clamp to a valid, non-empty range. const winStart = Math.max(0, Math.min(1, trimStartFraction ?? 0)); const winEnd = Math.max(winStart, Math.min(1, trimEndFraction ?? 1)); const lo = Math.floor(winStart * peaks.length); const hi = Math.max(lo + 1, Math.ceil(winEnd * peaks.length)); const span = hi - lo; // Fill the full (possibly zoomed) clip width with STEP-spaced bars, resampling // the windowed peaks across them — upsampling (repeating peaks) when the clip // is wider than the slice has samples, so the waveform stretches with zoom // instead of stopping partway across. const w = container.clientWidth || 400; const barCount = Math.max(0, Math.floor(w / STEP)); let html = ""; for (let i = 0; i < barCount; i++) { // Map bar index to peak index within the windowed range (resample) const peakIdx = lo + Math.min(span - 1, Math.floor((i / barCount) * span)); const amp = peaks[peakIdx] ?? 0; const pct = Math.max(3, Math.round(amp * 100)); const opacity = (0.45 + amp * 0.4).toFixed(2); html += `
`; } barsEl.innerHTML = html; }, [peaks, trimStartFraction, trimEndFraction]); // Observe container size and redraw const setContainerRef = useCallback( (el: HTMLDivElement | null) => { roRef.current?.disconnect(); containerRef.current = el; if (!el) return; draw(); roRef.current = new ResizeObserver(() => draw()); roRef.current.observe(el); }, [draw], ); // Redraw when peaks arrive useEffect(() => { draw(); }, [draw]); useEffect( () => () => { roRef.current?.disconnect(); }, [], ); return (
{/* Shimmer while decoding */} {!peaks && (
)}
{label}
); });