feat(timeline): add audio waveform visualisation on clip track

This commit is contained in:
webadderall
2026-04-02 16:29:39 +11:00
parent 671a59e847
commit a5bf98b27e
2 changed files with 176 additions and 0 deletions
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTimelineContext } from "dnd-timeline";
import type { AudioPeaksData } from "./useAudioPeaks";
interface AudioWaveformProps {
peaks: AudioPeaksData;
}
/**
* Renders an audio waveform as a canvas that fills its parent container.
* Automatically syncs with the timeline's visible range so the waveform
* scrolls and zooms together with the clip items above it.
*/
export default function AudioWaveform({ peaks }: AudioWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { range } = useTimelineContext();
const [resizeKey, setResizeKey] = useState(0);
// Bump resizeKey when the canvas element changes size.
const observerRef = useRef<ResizeObserver | null>(null);
const setCanvasRef = useCallback((node: HTMLCanvasElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
(canvasRef as React.MutableRefObject<HTMLCanvasElement | null>).current = node;
if (node) {
const ro = new ResizeObserver(() => setResizeKey((k) => k + 1));
ro.observe(node);
observerRef.current = ro;
}
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.round(rect.width * dpr);
const height = Math.round(rect.height * dpr);
if (width === 0 || height === 0) return;
canvas.width = width;
canvas.height = height;
ctx.clearRect(0, 0, width, height);
const { peaks: peakData, durationMs } = peaks;
if (durationMs <= 0 || peakData.length === 0) return;
const visibleStartMs = range.start;
const visibleEndMs = range.end;
const visibleDurationMs = visibleEndMs - visibleStartMs;
if (visibleDurationMs <= 0) return;
const midY = height / 2;
ctx.beginPath();
for (let px = 0; px < width; px++) {
const t = visibleStartMs + (px / width) * visibleDurationMs;
const binIndex = Math.min(
peakData.length - 1,
Math.max(0, Math.floor((t / durationMs) * peakData.length)),
);
const amplitude = peakData[binIndex];
const barHeight = amplitude * midY * 0.85;
ctx.moveTo(px, midY - barHeight);
ctx.lineTo(px, midY + barHeight);
}
ctx.strokeStyle = "rgba(6, 182, 212, 0.25)";
ctx.lineWidth = dpr;
ctx.stroke();
}, [peaks, range.start, range.end, resizeKey]);
return (
<canvas
ref={setCanvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 0, opacity: 0.9 }}
/>
);
}
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
/** Number of peak bins to produce — enough for smooth display at any zoom. */
const TARGET_PEAK_COUNT = 2048;
export interface AudioPeaksData {
/** One normalised amplitude value (0–1) per bin, covering the full duration. */
peaks: Float32Array;
/** Total duration of the decoded audio in milliseconds. */
durationMs: number;
}
/**
* Decode audio from a media file URL and produce a fixed-length array of peak
* amplitudes suitable for waveform visualisation.
*
* Returns `null` while loading or if the file has no decodeable audio.
*/
export function useAudioPeaks(fileUrl: string | null | undefined): AudioPeaksData | null {
const [data, setData] = useState<AudioPeaksData | null>(null);
const urlRef = useRef(fileUrl);
useEffect(() => {
urlRef.current = fileUrl;
setData(null);
if (!fileUrl) {
return;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(fileUrl);
if (cancelled) return;
const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
const audioCtx = new OfflineAudioContext(1, 1, 44100);
const decoded = await audioCtx.decodeAudioData(arrayBuffer);
if (cancelled) return;
const channelData = decoded.getChannelData(0);
const durationMs = decoded.duration * 1000;
const binSize = Math.max(1, Math.floor(channelData.length / TARGET_PEAK_COUNT));
const peakCount = Math.ceil(channelData.length / binSize);
const peaks = new Float32Array(peakCount);
for (let i = 0; i < peakCount; i++) {
const start = i * binSize;
const end = Math.min(start + binSize, channelData.length);
let max = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(channelData[j]);
if (abs > max) max = abs;
}
peaks[i] = max;
}
// Normalise to 0–1 range.
let globalMax = 0;
for (let i = 0; i < peaks.length; i++) {
if (peaks[i] > globalMax) globalMax = peaks[i];
}
if (globalMax > 0) {
for (let i = 0; i < peaks.length; i++) {
peaks[i] /= globalMax;
}
}
if (!cancelled && urlRef.current === fileUrl) {
setData({ peaks, durationMs });
}
} catch {
// File has no audio or decoding failed — leave as null.
}
})();
return () => {
cancelled = true;
};
}, [fileUrl]);
return data;
}