yue2-studio / src /components /waveform.tsx
RalphThings's picture
Upload folder using huggingface_hub
bf68acd verified
Raw History Blame Contribute Delete
4.01 kB
"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<Float32Array | null>(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<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(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 (
<div
ref={wrapRef}
className={onSeek ? "w-full cursor-pointer" : "w-full"}
onClick={(event) => {
if (!onSeek) return;
const rect = event.currentTarget.getBoundingClientRect();
onSeek(Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)));
}}
>
<canvas ref={canvasRef} aria-hidden />
</div>
);
}