Files
hyperframes/packages/studio/src/player/components/AudioWaveform.tsx
T

184 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo, useRef, useState, useCallback, useEffect } from "react";
interface AudioWaveformProps {
audioUrl: string;
waveformUrl?: string;
label: string;
labelColor: string;
}
const BAR_W = 2;
const GAP = 1;
const STEP = BAR_W + GAP;
/** Downsample PCM channel data into peak amplitudes (01). */
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<string, number[]>();
const decodeInFlight = new Map<string, Promise<number[]>>();
/**
* 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,
}: AudioWaveformProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const barsRef = useRef<HTMLDivElement | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const cacheKey = waveformUrl ?? audioUrl;
const [peaks, setPeaks] = useState<number[] | null>(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;
const w = container.clientWidth || 400;
const barCount = Math.min(Math.floor(w / STEP), peaks.length);
let html = "";
for (let i = 0; i < barCount; i++) {
// Map bar index to peak index (resample)
const peakIdx = Math.floor((i / barCount) * peaks.length);
const amp = peaks[peakIdx] ?? 0;
const pct = Math.max(3, Math.round(amp * 100));
const opacity = (0.45 + amp * 0.4).toFixed(2);
html += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
}
barsEl.innerHTML = html;
}, [peaks]);
// 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 (
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
<div ref={barsRef} className="absolute left-0 right-0 bottom-0" style={{ top: 16 }} />
{/* Shimmer while decoding */}
{!peaks && (
<div
className="absolute left-0 right-0 bottom-0 animate-pulse"
style={{
top: 16,
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
</div>
);
});