diff --git a/src/components/video-editor/timeline/AudioWaveform.tsx b/src/components/video-editor/timeline/AudioWaveform.tsx new file mode 100644 index 00000000..3c2d20dc --- /dev/null +++ b/src/components/video-editor/timeline/AudioWaveform.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTimelineContext } from "dnd-timeline"; +import type { AudioPeaksData } from "./useAudioPeaks"; + +interface AudioWaveformProps { + peaks: AudioPeaksData; +} + +/** + * Renders an audio waveform as a canvas that fills its parent container. + * Automatically syncs with the timeline's visible range so the waveform + * scrolls and zooms together with the clip items above it. + */ +export default function AudioWaveform({ peaks }: AudioWaveformProps) { + const canvasRef = useRef(null); + const { range } = useTimelineContext(); + const [resizeKey, setResizeKey] = useState(0); + + // Bump resizeKey when the canvas element changes size. + const observerRef = useRef(null); + const setCanvasRef = useCallback((node: HTMLCanvasElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + (canvasRef as React.MutableRefObject).current = node; + if (node) { + const ro = new ResizeObserver(() => setResizeKey((k) => k + 1)); + ro.observe(node); + observerRef.current = ro; + } + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.round(rect.width * dpr); + const height = Math.round(rect.height * dpr); + + if (width === 0 || height === 0) return; + + canvas.width = width; + canvas.height = height; + + ctx.clearRect(0, 0, width, height); + + const { peaks: peakData, durationMs } = peaks; + if (durationMs <= 0 || peakData.length === 0) return; + + const visibleStartMs = range.start; + const visibleEndMs = range.end; + const visibleDurationMs = visibleEndMs - visibleStartMs; + if (visibleDurationMs <= 0) return; + + const midY = height / 2; + + ctx.beginPath(); + for (let px = 0; px < width; px++) { + const t = visibleStartMs + (px / width) * visibleDurationMs; + const binIndex = Math.min( + peakData.length - 1, + Math.max(0, Math.floor((t / durationMs) * peakData.length)), + ); + const amplitude = peakData[binIndex]; + const barHeight = amplitude * midY * 0.85; + + ctx.moveTo(px, midY - barHeight); + ctx.lineTo(px, midY + barHeight); + } + + ctx.strokeStyle = "rgba(6, 182, 212, 0.25)"; + ctx.lineWidth = dpr; + ctx.stroke(); + }, [peaks, range.start, range.end, resizeKey]); + + return ( + + ); +} diff --git a/src/components/video-editor/timeline/useAudioPeaks.ts b/src/components/video-editor/timeline/useAudioPeaks.ts new file mode 100644 index 00000000..587659fd --- /dev/null +++ b/src/components/video-editor/timeline/useAudioPeaks.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; + +/** Number of peak bins to produce — enough for smooth display at any zoom. */ +const TARGET_PEAK_COUNT = 2048; + +export interface AudioPeaksData { + /** One normalised amplitude value (0–1) per bin, covering the full duration. */ + peaks: Float32Array; + /** Total duration of the decoded audio in milliseconds. */ + durationMs: number; +} + +/** + * Decode audio from a media file URL and produce a fixed-length array of peak + * amplitudes suitable for waveform visualisation. + * + * Returns `null` while loading or if the file has no decodeable audio. + */ +export function useAudioPeaks(fileUrl: string | null | undefined): AudioPeaksData | null { + const [data, setData] = useState(null); + const urlRef = useRef(fileUrl); + + useEffect(() => { + urlRef.current = fileUrl; + setData(null); + + if (!fileUrl) { + return; + } + + let cancelled = false; + + (async () => { + try { + const response = await fetch(fileUrl); + if (cancelled) return; + + const arrayBuffer = await response.arrayBuffer(); + if (cancelled) return; + + const audioCtx = new OfflineAudioContext(1, 1, 44100); + const decoded = await audioCtx.decodeAudioData(arrayBuffer); + if (cancelled) return; + + const channelData = decoded.getChannelData(0); + const durationMs = decoded.duration * 1000; + const binSize = Math.max(1, Math.floor(channelData.length / TARGET_PEAK_COUNT)); + const peakCount = Math.ceil(channelData.length / binSize); + const peaks = new Float32Array(peakCount); + + for (let i = 0; i < peakCount; i++) { + const start = i * binSize; + const end = Math.min(start + binSize, channelData.length); + let max = 0; + for (let j = start; j < end; j++) { + const abs = Math.abs(channelData[j]); + if (abs > max) max = abs; + } + peaks[i] = max; + } + + // Normalise to 0–1 range. + let globalMax = 0; + for (let i = 0; i < peaks.length; i++) { + if (peaks[i] > globalMax) globalMax = peaks[i]; + } + if (globalMax > 0) { + for (let i = 0; i < peaks.length; i++) { + peaks[i] /= globalMax; + } + } + + if (!cancelled && urlRef.current === fileUrl) { + setData({ peaks, durationMs }); + } + } catch { + // File has no audio or decoding failed — leave as null. + } + })(); + + return () => { + cancelled = true; + }; + }, [fileUrl]); + + return data; +}