refactor: extract audio peak decoding into useAudioPeaks hook

Move fetch/decode/cache logic out of RowWaveform into a standalone
useAudioPeaks hook. RowWaveform is now a pure canvas renderer that
accepts pre-computed peaks as a prop. TimelineEditor calls the hook
and passes peaks down, skipping decoding entirely when waveform is off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Davide Mendolia
2026-05-23 17:14:20 +02:00
co-authored by Claude Sonnet 4.6
parent 46732fc76b
commit a72aa1a594
3 changed files with 97 additions and 80 deletions
@@ -2,100 +2,25 @@ import { useTimelineContext } from "dnd-timeline";
import { useEffect, useRef, useState } from "react";
export interface RowWaveformProps {
videoUrl?: string;
/** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */
peaks: Float32Array | null;
videoDurationMs: number;
}
// Module-level cache keyed by URL — survives re-mounts within the same page session.
const peaksCache = new Map<string, Float32Array>();
let _audioCtx: AudioContext | null = null;
function getAudioCtx(): AudioContext {
if (!_audioCtx) _audioCtx = new AudioContext();
return _audioCtx;
}
function computePeaks(audioBuffer: AudioBuffer): Float32Array {
const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200));
const nCh = audioBuffer.numberOfChannels;
const totalSamples = audioBuffer.length;
const blockSize = totalSamples / N;
const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …]
const channels: Float32Array[] = [];
for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c));
for (let i = 0; i < N; i++) {
const start = Math.floor(i * blockSize);
const end = Math.floor((i + 1) * blockSize);
let minVal = 0;
let maxVal = 0;
for (let j = start; j < end; j++) {
let sample = 0;
for (let c = 0; c < nCh; c++) sample += channels[c][j];
sample /= nCh;
if (sample < minVal) minVal = sample;
if (sample > maxVal) maxVal = sample;
}
peaks[i * 2] = minVal;
peaks[i * 2 + 1] = maxVal;
}
return peaks;
}
/**
* Renders a faint audio waveform on a `<canvas>` element that fills its
* containing row. Designed to be passed as the `background` prop of `<Row>`.
*
* - Decodes audio from `videoUrl` once per URL (module-level cache).
* - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`).
* - Redraws whenever the timeline zoom/pan range changes.
* - `pointer-events: none` throughout — never blocks drag-to-create interactions.
* - Silent fallback when the file has no audio track.
*/
export default function RowWaveform({ videoUrl, videoDurationMs }: RowWaveformProps) {
export default function RowWaveform({ peaks, videoDurationMs }: RowWaveformProps) {
const { range } = useTimelineContext();
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
// Decode audio once per videoUrl, store peaks in module-level cache.
useEffect(() => {
if (!videoUrl) {
setPeaks(null);
return;
}
const cached = peaksCache.get(videoUrl);
if (cached) {
setPeaks(cached);
return;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(videoUrl);
if (cancelled) return;
const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer);
if (cancelled) return;
const p = computePeaks(audioBuffer);
peaksCache.set(videoUrl, p);
setPeaks(p);
} catch {
// No audio track or unsupported format — silent degradation.
}
})();
return () => {
cancelled = true;
};
}, [videoUrl]);
// Track container dimensions via ResizeObserver.
useEffect(() => {
const el = wrapperRef.current;
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
@@ -597,6 +598,7 @@ function Timeline({
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const isScrubbingTimelineRef = useRef(false);
const scrubPointerIdRef = useRef<number | null>(null);
const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
@@ -801,7 +803,7 @@ function Timeline({
hint={t("hints.pressTrim")}
background={
showTrimWaveform ? (
<RowWaveform videoUrl={videoUrl} videoDurationMs={videoDurationMs} />
<RowWaveform peaks={peaks} videoDurationMs={videoDurationMs} />
) : undefined
}
>
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
// Module-level cache keyed by URL — survives re-mounts within the same page session.
const peaksCache = new Map<string, Float32Array>();
let _audioCtx: AudioContext | null = null;
function getAudioCtx(): AudioContext {
if (!_audioCtx) _audioCtx = new AudioContext();
return _audioCtx;
}
function computePeaks(audioBuffer: AudioBuffer): Float32Array {
const N = Math.min(24000, Math.ceil(audioBuffer.duration * 200));
const nCh = audioBuffer.numberOfChannels;
const totalSamples = audioBuffer.length;
const blockSize = totalSamples / N;
const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …]
const channels: Float32Array[] = [];
for (let c = 0; c < nCh; c++) channels.push(audioBuffer.getChannelData(c));
for (let i = 0; i < N; i++) {
const start = Math.floor(i * blockSize);
const end = Math.floor((i + 1) * blockSize);
let minVal = 0;
let maxVal = 0;
for (let j = start; j < end; j++) {
let sample = 0;
for (let c = 0; c < nCh; c++) sample += channels[c][j];
sample /= nCh;
if (sample < minVal) minVal = sample;
if (sample > maxVal) maxVal = sample;
}
peaks[i * 2] = minVal;
peaks[i * 2 + 1] = maxVal;
}
return peaks;
}
/**
* Decodes audio from `videoUrl` and returns a Float32Array of paired
* [min, max] peak values (length = 2 * N blocks). Returns `null` while
* decoding is in progress, and stays `null` when the file has no audio
* track or decoding fails (silent degradation).
*
* Results are cached at module scope by URL so re-mounts are free.
*/
export function useAudioPeaks(videoUrl?: string): Float32Array | null {
const [peaks, setPeaks] = useState<Float32Array | null>(() =>
videoUrl ? (peaksCache.get(videoUrl) ?? null) : null,
);
useEffect(() => {
if (!videoUrl) {
setPeaks(null);
return;
}
const cached = peaksCache.get(videoUrl);
if (cached) {
setPeaks(cached);
return;
}
let cancelled = false;
(async () => {
try {
const response = await fetch(videoUrl);
if (cancelled) return;
const arrayBuffer = await response.arrayBuffer();
if (cancelled) return;
const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer);
if (cancelled) return;
const p = computePeaks(audioBuffer);
peaksCache.set(videoUrl, p);
setPeaks(p);
} catch {
// No audio track or unsupported format — silent degradation.
}
})();
return () => {
cancelled = true;
};
}, [videoUrl]);
return peaks;
}