Files
hyperframes/packages/studio/src/player/components/AudioWaveform.tsx
T
Miguel Ángel 037266e72b feat(studio): timeline revamp with active-clip highlighting and hide controls (#2017)
Timeline UI
- Highlight clips visible at the playhead in the primary color; others share one neutral color
- Minimalist rounded clips, single-color track rows, no gutter icons or superscript labels
- Per-track eye toggle and a per-element hide button in the design panel
- Ruler zoom fixes: sub-second tick intervals and correct label formatting at high zoom
- Sticky gutter so track controls stay visible while scrolling

WYSIWYG visibility (data-hidden)
- Runtime honors data-hidden (display:none), so hiding affects the render, not just the preview
- HTML stays the source of truth; hide state persists and round-trips on reload

Split several studio files to stay under the 600-line cap; pure relocations, no behavior change.
2026-07-07 04:26:56 -04:00

212 lines
7.1 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;
/**
* Fraction (01) of the source the clip starts at, after the media-start
* trim. Defaults to 0 (no front trim).
*/
trimStartFraction?: number;
/**
* Fraction (01) 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 (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++) {
// fallow-ignore-next-line code-duplication
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,
trimStartFraction,
trimEndFraction,
}: 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;
// 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 += `<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, 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 (
<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%)",
}}
/>
)}
{label && (
<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>
);
});