summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
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.tsx271
1 files changed, 131 insertions, 140 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 bbc5cb83..cbb19bd0 100644
--- a/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
+++ b/apps/web/src/components/editor/panels/timeline/audio-waveform.tsx
@@ -1,176 +1,167 @@
-import { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
+"use client";
+import { useCallback, useEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { computeGlobalMaxRms, extractRmsRange } from "@/lib/media/audio";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+const BAR_WIDTH = 2;
+const BAR_GAP = 1;
+const BAR_STEP = BAR_WIDTH + BAR_GAP;
interface AudioWaveformProps {
audioUrl?: string;
audioBuffer?: AudioBuffer;
- height?: number;
+ color?: string;
className?: string;
}
-function extractPeaks({
- buffer,
- length = 512,
-}: {
- buffer: AudioBuffer;
- length?: number;
-}): number[][] {
- const channels = buffer.numberOfChannels;
- const peaks: number[][] = [];
-
- for (let c = 0; c < channels; c++) {
- const data = buffer.getChannelData(c);
- const step = Math.floor(data.length / length);
- const channelPeaks: number[] = [];
-
- for (let i = 0; i < length; i++) {
- const start = i * step;
- const end = Math.min(start + step, data.length);
- let max = 0;
- for (let j = start; j < end; j++) {
- const abs = Math.abs(data[j]);
- if (abs > max) max = abs;
- }
- channelPeaks.push(max);
- }
- peaks.push(channelPeaks);
- }
-
- return peaks;
-}
-
export function AudioWaveform({
audioUrl,
audioBuffer,
- height = 32,
+ color = "rgba(255, 255, 255, 0.7)",
className = "",
}: AudioWaveformProps) {
- const waveformRef = useRef<HTMLDivElement>(null);
- const wavesurfer = useRef<WaveSurfer | null>(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(false);
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const bufferRef = useRef<AudioBuffer | null>(null);
+ const globalMaxRef = useRef<number>(1);
+ const scrollParentRef = useRef<HTMLElement | null>(null);
+ const heightRef = useRef<number>(0);
- useEffect(() => {
- let mounted = true;
- const ws = wavesurfer.current;
+ const drawVisible = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ const buffer = bufferRef.current;
+ const height = heightRef.current;
- const initWaveSurfer = async () => {
- if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+ if (!container || !canvas || !buffer || height <= 0) return;
- try {
- if (ws) {
- wavesurfer.current = null;
- }
+ const elementWidth = container.offsetWidth;
+ if (elementWidth <= 0) return;
- const newWaveSurfer = WaveSurfer.create({
- container: waveformRef.current,
- waveColor: "rgba(255, 255, 255, 0.6)",
- progressColor: "rgba(255, 255, 255, 0.9)",
- cursorColor: "transparent",
- barWidth: 2,
- barGap: 1,
- height,
- normalize: true,
- interact: false,
- });
+ const containerRect = container.getBoundingClientRect();
+ const scrollParent = scrollParentRef.current;
- if (mounted) {
- wavesurfer.current = newWaveSurfer;
- } else {
- try {
- newWaveSurfer.destroy();
- } catch {}
- return;
- }
+ let clipLeft: number;
+ let clipRight: number;
- newWaveSurfer.on("ready", () => {
- if (mounted) {
- setIsLoading(false);
- setError(false);
- }
- });
+ 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,
+ );
+ }
- newWaveSurfer.on("error", (err) => {
- if (mounted) {
- console.error("WaveSurfer error:", err);
- setError(true);
- setIsLoading(false);
- }
- });
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) return;
- if (audioBuffer) {
- const peaks = extractPeaks({ buffer: audioBuffer });
- newWaveSurfer.load("", peaks, audioBuffer.duration);
- } else if (audioUrl) {
- await newWaveSurfer.load(audioUrl);
- }
- } catch (err) {
- if (mounted) {
- console.error("Failed to initialize WaveSurfer:", err);
- setError(true);
- setIsLoading(false);
- }
- }
- };
+ const dpr = window.devicePixelRatio || 1;
+ const canvasW = Math.round(visibleWidth * dpr);
+ const canvasH = Math.round(height * dpr);
+
+ if (canvasW <= 0 || canvasH <= 0) return;
+
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+ canvas.style.width = `${visibleWidth}px`;
+ 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),
+ );
- if (ws) {
- const wsToDestroy = ws;
- wavesurfer.current = null;
+ const peaks = extractRmsRange({
+ buffer,
+ count: barCount,
+ startSample,
+ endSample,
+ globalMax: globalMaxRef.current,
+ });
- requestAnimationFrame(() => {
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, canvasW, canvasH);
+ ctx.scale(dpr, dpr);
+ ctx.fillStyle = color;
+
+ const maxBarHeight = height * 0.7;
+
+ for (let i = 0; i < barCount; i++) {
+ const scaled = Math.log1p(peaks[i]) / Math.log1p(1);
+ const barH = Math.max(1, scaled * maxBarHeight);
+ ctx.fillRect(i * BAR_STEP, height - barH, BAR_WIDTH, barH);
+ }
+ }, [color]);
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ async function load() {
+ let buffer = audioBuffer ?? null;
+
+ if (!buffer && audioUrl) {
try {
- wsToDestroy.destroy();
- } catch {}
- if (mounted) {
- initWaveSurfer();
+ const resp = await fetch(audioUrl);
+ const arrayBuffer = await resp.arrayBuffer();
+ const actx = new AudioContext();
+ buffer = await actx.decodeAudioData(arrayBuffer);
+ actx.close();
+ } catch {
+ return;
}
- });
- } else {
- initWaveSurfer();
+ }
+
+ if (!buffer || isCancelled) return;
+
+ bufferRef.current = buffer;
+ globalMaxRef.current = computeGlobalMaxRms({ buffer });
+ drawVisible();
}
+ load();
return () => {
- mounted = false;
+ isCancelled = true;
+ };
+ }, [audioUrl, audioBuffer, drawVisible]);
- const wsToDestroy = wavesurfer.current;
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
- wavesurfer.current = null;
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) return;
- if (wsToDestroy) {
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch {}
- });
- }
- };
- }, [audioUrl, audioBuffer, height]);
+ scrollParent.addEventListener("scroll", drawVisible, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", drawVisible);
+ }, [drawVisible]);
- if (error) {
- return (
- <div
- className={`flex items-center justify-center ${className}`}
- style={{ height }}
- >
- <span className="text-foreground/60 text-xs">Audio unavailable</span>
- </div>
- );
- }
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
return (
- <div className={`relative ${className}`}>
- {isLoading && (
- <div className="absolute inset-0 flex items-center justify-center">
- <span className="text-foreground/60 text-xs">Loading...</span>
- </div>
- )}
- <div
- ref={waveformRef}
- className={`w-full ${isLoading ? "opacity-0" : "opacity-100"}`}
- style={{ height }}
- />
+ <div ref={containerRef} className={cn("relative size-full", className)}>
+ <canvas ref={canvasRef} className="absolute bottom-0" />
</div>
);
}
-
-export default AudioWaveform;