summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
diff options
context:
space:
mode:
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.tsx345
1 files changed, 0 insertions, 345 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
deleted file mode 100644
index a94267dd..00000000
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"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 "@/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 = 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>
- );
-}