mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
feat(timeline): add audio waveform visualisation on clip track
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user