"use client"; import { useEffect, useRef, useState } from "react"; /** Decodes audio once and reduces it to per-column peak pairs for drawing. */ export function useAudioPeaks(source: string | File | null, columns = 900) { const [peaks, setPeaks] = useState(null); const [duration, setDuration] = useState(0); const [failed, setFailed] = useState(false); useEffect(() => { if (!source) { setPeaks(null); setDuration(0); setFailed(false); return; } let cancelled = false; setFailed(false); setPeaks(null); const run = async () => { try { const bytes = typeof source === "string" ? await (await fetch(source)).arrayBuffer() : await source.arrayBuffer(); const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; const context = new AudioCtx(); const buffer = await context.decodeAudioData(bytes); await context.close(); if (cancelled) return; const channel = buffer.getChannelData(0); const perColumn = Math.max(1, Math.floor(channel.length / columns)); const result = new Float32Array(columns); for (let column = 0; column < columns; column += 1) { const start = column * perColumn; let peak = 0; for (let i = 0; i < perColumn; i += 1) { const value = Math.abs(channel[start + i] ?? 0); if (value > peak) peak = value; } result[column] = peak; } setPeaks(result); setDuration(buffer.duration); } catch { if (!cancelled) setFailed(true); } }; void run(); return () => { cancelled = true; }; }, [source, columns]); return { peaks, duration, failed }; } export function Waveform({ peaks, progress, onSeek, height = 56, idle = false, }: { peaks: Float32Array | null; progress: number; onSeek?: (ratio: number) => void; height?: number; idle?: boolean; }) { const canvasRef = useRef(null); const wrapRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const wrap = wrapRef.current; if (!canvas || !wrap) return; const draw = () => { const ratio = window.devicePixelRatio || 1; const width = wrap.clientWidth; canvas.width = width * ratio; canvas.height = height * ratio; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; const context = canvas.getContext("2d"); if (!context) return; context.scale(ratio, ratio); context.clearRect(0, 0, width, height); const columnWidth = 2; const gap = 1; const total = Math.floor(width / (columnWidth + gap)); const middle = height / 2; const playedColumns = Math.round(total * progress); for (let i = 0; i < total; i += 1) { const value = peaks ? (peaks[Math.floor((i / total) * peaks.length)] ?? 0) : idle ? 0.06 : 0.06 + 0.04 * Math.sin(i / 3); const amplitude = Math.max(1.5, value * (height / 2) * 0.94); context.fillStyle = i <= playedColumns && peaks ? "#c7e84f" : "#33333b"; context.fillRect(i * (columnWidth + gap), middle - amplitude, columnWidth, amplitude * 2); } }; draw(); const observer = new ResizeObserver(draw); observer.observe(wrap); return () => observer.disconnect(); }, [peaks, progress, height, idle]); return (
{ if (!onSeek) return; const rect = event.currentTarget.getBoundingClientRect(); onSeek(Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))); }} >
); }