summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/audio-waveform.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/audio-waveform.tsx
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/audio-waveform.tsx')
-rw-r--r--apps/web/src/timeline/components/audio-waveform.tsx345
1 files changed, 345 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx
new file mode 100644
index 00000000..7777bc09
--- /dev/null
+++ b/apps/web/src/timeline/components/audio-waveform.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme";
+import {
+ buildWaveformSampleBuckets,
+ sampleSourceWaveformSummary,
+ type SourceWaveformSummary,
+} from "@/media/waveform-summary";
+import type { RetimeConfig } from "@/timeline";
+import { getBarFractionFromOutputAmplitude } from "@/timeline/audio-display";
+import { waveformCache } from "@/services/waveform-cache/service";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+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 sampleGainAtClipTime({
+ samples,
+ clipTimeSec,
+ clipDurationSec,
+}: {
+ samples: number[];
+ clipTimeSec: number;
+ clipDurationSec: number;
+}): number {
+ 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,
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ color = TIMELINE_AUDIO_WAVEFORM_COLOR,
+ burnColor = WAVEFORM_BURN_COLOR,
+ className = "",
+}: AudioWaveformProps) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ 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 summary = summaryRef.current;
+ const height = heightRef.current;
+
+ 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;
+ let clipRight: number;
+
+ if (scrollParent) {
+ const parentRect = scrollParent.getBoundingClientRect();
+ clipLeft = Math.max(0, parentRect.left - containerRect.left);
+ clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
+ } else {
+ clipLeft = Math.max(0, -containerRect.left);
+ clipRight = Math.min(
+ elementWidth,
+ window.innerWidth - containerRect.left,
+ );
+ }
+
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) {
+ clearCanvas();
+ return;
+ }
+
+ const dpr = window.devicePixelRatio || 1;
+ 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;
+ canvas.style.width = `${visibleWidth}px`;
+ canvas.style.height = `${height}px`;
+ canvas.style.left = `${clipLeft}px`;
+
+ const backingScaleX = dpr;
+ const backingScaleY = canvasH / height;
+
+ 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;
+ }
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvasW, canvasH);
+
+ const clipBottom = canvasH;
+
+ 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
+ ? sampleGainAtClipTime({
+ samples,
+ clipTimeSec: clipCenterSec,
+ clipDurationSec: clipDurationSecValue,
+ })
+ : 1;
+ 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;
+ }
+
+ 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();
+
+ void waveformCache
+ .getSourceSummary({
+ sourceKey,
+ audioBuffer,
+ sourceFile,
+ audioUrl,
+ })
+ .then((summary) => {
+ if (isCancelled) {
+ return;
+ }
+ 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();
+ }
+ });
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [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;
+ }
+
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) {
+ return;
+ }
+
+ const handleScroll = () => {
+ drawVisible();
+ };
+ scrollParent.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", handleScroll);
+ }, [drawVisible]);
+
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
+
+ return (
+ <div ref={containerRef} className={cn("relative size-full", className)}>
+ <canvas ref={canvasRef} className="absolute bottom-0" />
+ </div>
+ );
+}