summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-18 01:40:18 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-18 01:40:18 +0200
commit011f51051c9750706978d6c89ce84dd15560c335 (patch)
tree2e7b23ebebb798934d6cb6aeace855f31699b633 /apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
parentc46d28891fee62ca2d3132da876af6411a41d9da (diff)
downloadopencut-011f51051c9750706978d6c89ce84dd15560c335.tar.gz
feat: clip volume line and timeline waveform improvements
Diffstat (limited to 'apps/web/src/components/editor/panels/timeline/audio-waveform.tsx')
-rw-r--r--apps/web/src/components/editor/panels/timeline/audio-waveform.tsx311
1 files changed, 222 insertions, 89 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
index 430575e1..d2442a84 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,73 +1,123 @@
"use client";
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useResizeObserver } from "@/hooks/use-resize-observer";
-import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import {
+ buildWaveformSampleBuckets,
+ sampleSourceWaveformSummary,
+ type SourceWaveformSummary,
+} from "@/lib/media/waveform-summary";
+import type { RetimeConfig } from "@/lib/timeline";
+import { getBarFractionFromOutputAmplitude } from "@/lib/timeline/audio-display";
+import { waveformCache } from "@/services/waveform-cache/service";
import { findScrollParent } from "@/utils/browser";
import { cn } from "@/utils/ui";
-const BAR_WIDTH = 2;
+const BAR_WIDTH = 1;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
+const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)";
export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
-function sampleGain({
+function sampleGainAtClipTime({
samples,
- startFraction,
- endFraction,
- barIndex,
- barCount,
+ clipTimeSec,
+ clipDurationSec,
}: {
samples: number[];
- startFraction: number;
- endFraction: number;
- barIndex: number;
- barCount: number;
+ clipTimeSec: number;
+ clipDurationSec: number;
}): number {
- if (samples.length === 0) return 1;
- const progress =
- startFraction +
- ((barIndex + 0.5) / barCount) * (endFraction - startFraction);
- const rawIndex = Math.max(0, Math.min(1, progress)) * (samples.length - 1);
+ if (samples.length === 0 || clipDurationSec <= 0) {
+ return 1;
+ }
+
+ const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec));
+ const rawIndex = progress * (samples.length - 1);
const lo = Math.floor(rawIndex);
const hi = Math.min(samples.length - 1, lo + 1);
return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
}
+
interface AudioWaveformProps {
+ sourceKey: string;
+ sourceFile?: File;
audioUrl?: string;
audioBuffer?: AudioBuffer;
gainSamples?: number[];
+ pixelsPerSecond: number;
+ clipDurationSec: number;
+ retime?: RetimeConfig;
+ sourceStartSec: number;
color?: string;
+ burnColor?: string;
className?: string;
}
export function AudioWaveform({
+ sourceKey,
+ sourceFile,
audioUrl,
audioBuffer,
gainSamples,
- color = "rgba(255, 255, 255, 0.7)",
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ color,
+ burnColor = WAVEFORM_BURN_COLOR,
className = "",
}: AudioWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
- const bufferRef = useRef<AudioBuffer | null>(null);
- const globalMaxRef = useRef<number>(1);
+ const summaryRef = useRef<SourceWaveformSummary | null>(null);
const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
+ const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
+ const clipDurationSecRef = useRef<number>(clipDurationSec);
+ const retimeRef = useRef<RetimeConfig | undefined>(retime);
+ const sourceStartSecRef = useRef<number>(sourceStartSec);
const scrollParentRef = useRef<HTMLElement | null>(null);
const heightRef = useRef<number>(0);
+ const lastRenderSignatureRef = useRef<string | null>(null);
+
+ gainSamplesRef.current = gainSamples;
+ pixelsPerSecondRef.current = pixelsPerSecond;
+ clipDurationSecRef.current = clipDurationSec;
+ retimeRef.current = retime;
+ sourceStartSecRef.current = sourceStartSec;
+
+ const clearCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ }
+ lastRenderSignatureRef.current = null;
+ }, []);
const drawVisible = useCallback(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
- const buffer = bufferRef.current;
+ const summary = summaryRef.current;
const height = heightRef.current;
- if (!container || !canvas || !buffer || height <= 0) return;
-
- const elementWidth = container.offsetWidth;
- if (elementWidth <= 0) return;
+ if (!container || !canvas || !summary || height <= 0) {
+ clearCanvas();
+ return;
+ }
const containerRect = container.getBoundingClientRect();
+ const elementWidth = containerRect.width;
+ if (elementWidth <= 0) {
+ clearCanvas();
+ return;
+ }
+
const scrollParent = scrollParentRef.current;
let clipLeft: number;
@@ -86,13 +136,43 @@ export function AudioWaveform({
}
const visibleWidth = clipRight - clipLeft;
- if (visibleWidth <= 0) return;
+ if (visibleWidth <= 0) {
+ clearCanvas();
+ return;
+ }
const dpr = window.devicePixelRatio || 1;
- const canvasW = Math.round(visibleWidth * dpr);
- const canvasH = Math.round(height * dpr);
-
- if (canvasW <= 0 || canvasH <= 0) return;
+ const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
+ const canvasH = Math.max(1, Math.round(height * dpr));
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const pixelsPerSecondValue = pixelsPerSecondRef.current;
+ const clipDurationSecValue = clipDurationSecRef.current;
+ const samples = gainSamplesRef.current;
+ const renderSignature = JSON.stringify({
+ elementWidth,
+ clipLeft,
+ clipRight,
+ visibleWidth,
+ canvasW,
+ canvasH,
+ barCount,
+ dpr,
+ clipDurationSec: clipDurationSecValue,
+ sourceStartSec: sourceStartSecRef.current,
+ pixelsPerSecond: pixelsPerSecondValue,
+ retime: retimeRef.current ?? null,
+ summarySourceKey: summary.sourceKey,
+ summarySampleRate: summary.sampleRate,
+ summaryTotalSamples: summary.totalSamples,
+ summaryBucketSize: summary.bucketSize,
+ gainSamples: samples ?? null,
+ color,
+ burnColor,
+ });
+ if (lastRenderSignatureRef.current === renderSignature) {
+ return;
+ }
+ lastRenderSignatureRef.current = renderSignature;
canvas.width = canvasW;
canvas.height = canvasH;
@@ -100,97 +180,150 @@ export function AudioWaveform({
canvas.style.height = `${height}px`;
canvas.style.left = `${clipLeft}px`;
- const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
- const startFraction = clipLeft / elementWidth;
- const endFraction = clipRight / elementWidth;
- const startSample = Math.floor(startFraction * buffer.length);
- const endSample = Math.min(
- buffer.length,
- Math.ceil(endFraction * buffer.length),
- );
+ const backingScaleX = dpr;
+ const backingScaleY = canvasH / height;
- const peaks = extractRmsRange({
- buffer,
- count: barCount,
- startSample,
- endSample,
- globalMax: globalMaxRef.current,
+ const sampleBuckets = buildWaveformSampleBuckets({
+ clipLeftPx: clipLeft,
+ clipRightPx: clipRight,
+ barCount,
+ pixelsPerSecond: pixelsPerSecondValue,
+ clipDurationSec: clipDurationSecValue,
+ sourceStartSec: sourceStartSecRef.current,
+ retime: retimeRef.current,
+ sampleRate: summary.sampleRate,
+ maxSampleExclusive: summary.totalSamples,
+ barStepPx: BAR_STEP,
+ });
+ const amplitudes = sampleSourceWaveformSummary({
+ summary,
+ buckets: sampleBuckets,
});
const ctx = canvas.getContext("2d");
- if (!ctx) return;
+ if (!ctx) {
+ return;
+ }
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvasW, canvasH);
- ctx.scale(dpr, dpr);
- ctx.fillStyle = color;
- const maxBarHeight = height * 0.7;
+ const clipBottom = canvasH;
- const samples = gainSamplesRef.current;
for (let i = 0; i < barCount; i++) {
+ const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5;
+ const clipCenterSec = Math.max(
+ 0,
+ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
+ );
const gain =
samples != null
- ? sampleGain({
+ ? sampleGainAtClipTime({
samples,
- startFraction,
- endFraction,
- barIndex: i,
- barCount,
+ clipTimeSec: clipCenterSec,
+ clipDurationSec: clipDurationSecValue,
})
: 1;
- const scaledPeak = Math.min(1, Math.max(0, peaks[i] * gain));
- const scaled = Math.log1p(scaledPeak) / Math.log1p(1);
- const barH = Math.max(1, scaled * maxBarHeight);
- ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
- }
- }, [color]);
+ const amplitude = Math.max(0, amplitudes[i] ?? 0);
+ const outputAmplitude = amplitude * Math.max(0, gain);
+ const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude });
+ const barH = fraction > 0 ? Math.max(1, fraction * height) : 0;
+ if (barH <= 0) {
+ continue;
+ }
- useEffect(() => {
- gainSamplesRef.current = gainSamples;
- drawVisible();
- }, [gainSamples, drawVisible]);
+ const barLeft = i * BAR_STEP;
+ const barRight = barLeft + BAR_WIDTH;
+ const deviceLeft = Math.round(barLeft * backingScaleX);
+ const deviceRight = Math.max(
+ deviceLeft + 1,
+ Math.round(barRight * backingScaleX),
+ );
+ const deviceTop = Math.round((height - barH) * backingScaleY);
+ const deviceHeight = Math.max(1, clipBottom - deviceTop);
+
+ ctx.fillStyle = color;
+ ctx.fillRect(
+ deviceLeft,
+ deviceTop,
+ deviceRight - deviceLeft,
+ deviceHeight,
+ );
+
+ if (outputAmplitude > 1) {
+ const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
+ ctx.fillStyle = burnColor;
+ ctx.fillRect(
+ deviceLeft,
+ deviceTop,
+ deviceRight - deviceLeft,
+ burnHeight,
+ );
+ }
+ }
+ }, [burnColor, clearCanvas, color]);
useEffect(() => {
let isCancelled = false;
+ summaryRef.current = null;
+ clearCanvas();
- async function load() {
- let buffer = audioBuffer ?? null;
-
- if (!buffer && audioUrl) {
- try {
- const resp = await fetch(audioUrl);
- const arrayBuffer = await resp.arrayBuffer();
- const actx = new AudioContext();
- buffer = await actx.decodeAudioData(arrayBuffer);
- actx.close();
- } catch {
+ void waveformCache
+ .getSourceSummary({
+ sourceKey,
+ audioBuffer,
+ sourceFile,
+ audioUrl,
+ })
+ .then((summary) => {
+ if (isCancelled) {
return;
}
- }
-
- if (!buffer || isCancelled) return;
-
- bufferRef.current = buffer;
- globalMaxRef.current = computeGlobalMaxRms({ buffer });
- drawVisible();
- }
+ summaryRef.current = summary;
+ drawVisible();
+ })
+ .catch(() => {
+ // Waveform loading failed (e.g. corrupt file, unsupported format).
+ // Fail silently — a missing waveform is preferable to an error state.
+ if (!isCancelled) {
+ clearCanvas();
+ }
+ });
- load();
return () => {
isCancelled = true;
};
- }, [audioUrl, audioBuffer, drawVisible]);
+ }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
+ useLayoutEffect(() => {
+ drawVisible();
+ }, [
+ drawVisible,
+ gainSamples,
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ ]);
useEffect(() => {
const container = containerRef.current;
- if (!container) return;
+ if (!container) {
+ return;
+ }
scrollParentRef.current = findScrollParent({ element: container });
const scrollParent = scrollParentRef.current;
- if (!scrollParent) return;
+ if (!scrollParent) {
+ return;
+ }
- scrollParent.addEventListener("scroll", drawVisible, { passive: true });
- return () => scrollParent.removeEventListener("scroll", drawVisible);
+ const handleScroll = () => {
+ drawVisible();
+ };
+ scrollParent.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", handleScroll);
}, [drawVisible]);
const onResize = useCallback(