Spaces:
Running
Running
| import { useEffect, useRef, useState } from "react"; | |
| // Custom audio player with bar-waveform viz and click-to-seek. | |
| // Pattern from victor/ace-step-jam; we render N bars pulled from decoded audio buffer peaks. | |
| const NUM_BARS = 80; | |
| export default function Waveform({ src, duration }) { | |
| const audioRef = useRef(null); | |
| const [peaks, setPeaks] = useState(null); | |
| const [playing, setPlaying] = useState(false); | |
| const [progress, setProgress] = useState(0); | |
| // Decode audio to extract bar peaks | |
| useEffect(() => { | |
| if (!src) return; | |
| let cancelled = false; | |
| (async () => { | |
| try { | |
| const res = await fetch(src); | |
| const buf = await res.arrayBuffer(); | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const audio = await ctx.decodeAudioData(buf.slice(0)); | |
| const channel = audio.getChannelData(0); | |
| const samplesPerBar = Math.floor(channel.length / NUM_BARS); | |
| const out = new Float32Array(NUM_BARS); | |
| let globalMax = 0; | |
| for (let b = 0; b < NUM_BARS; b++) { | |
| let max = 0; | |
| const start = b * samplesPerBar; | |
| const end = Math.min(start + samplesPerBar, channel.length); | |
| for (let i = start; i < end; i++) { | |
| const v = Math.abs(channel[i]); | |
| if (Number.isFinite(v) && v > max) max = v; | |
| } | |
| out[b] = max; | |
| if (max > globalMax) globalMax = max; | |
| } | |
| // Normalize — if silent or NaN, fall back to flat low bars | |
| const peak = Number.isFinite(globalMax) && globalMax > 1e-5 ? globalMax : 1; | |
| for (let i = 0; i < NUM_BARS; i++) { | |
| const n = out[i] / peak; | |
| out[i] = Number.isFinite(n) ? Math.max(0.05, Math.min(1, n)) : 0.05; | |
| } | |
| if (!cancelled) setPeaks(out); | |
| ctx.close?.(); | |
| } catch (e) { | |
| console.warn("waveform decode failed:", e); | |
| // Still show fallback bars so UI isn't broken | |
| if (!cancelled) setPeaks(new Float32Array(NUM_BARS).fill(0.1)); | |
| } | |
| })(); | |
| return () => { cancelled = true; }; | |
| }, [src]); | |
| useEffect(() => { | |
| const a = audioRef.current; | |
| if (!a) return; | |
| const onTime = () => setProgress(a.duration ? a.currentTime / a.duration : 0); | |
| const onEnd = () => setPlaying(false); | |
| a.addEventListener("timeupdate", onTime); | |
| a.addEventListener("ended", onEnd); | |
| return () => { | |
| a.removeEventListener("timeupdate", onTime); | |
| a.removeEventListener("ended", onEnd); | |
| }; | |
| }, [src]); | |
| const toggle = () => { | |
| const a = audioRef.current; | |
| if (!a) return; | |
| if (a.paused) { a.play(); setPlaying(true); } | |
| else { a.pause(); setPlaying(false); } | |
| }; | |
| const seek = (e) => { | |
| const a = audioRef.current; | |
| if (!a || !a.duration) return; | |
| const rect = e.currentTarget.getBoundingClientRect(); | |
| const x = (e.clientX - rect.left) / rect.width; | |
| a.currentTime = Math.max(0, Math.min(1, x)) * a.duration; | |
| setProgress(x); | |
| }; | |
| return ( | |
| <div className="flex items-center gap-3 w-full"> | |
| <audio ref={audioRef} src={src} preload="auto" /> | |
| <button | |
| onClick={toggle} | |
| className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center hover:scale-105 transition cursor-pointer" | |
| style={{ background: "var(--accent)", color: "var(--bg)" }} | |
| aria-label={playing ? "Pause" : "Play"} | |
| > | |
| {playing ? ( | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="3.5" height="12" rx="1" /><rect x="9.5" y="2" width="3.5" height="12" rx="1" /></svg> | |
| ) : ( | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M3.5 2.5v11a0.5 0.5 0 0 0 .8 .4l9 -5.5a0.5 0.5 0 0 0 0 -.8l-9 -5.5a0.5 0.5 0 0 0 -.8 .4z" /></svg> | |
| )} | |
| </button> | |
| <div | |
| onClick={seek} | |
| className="flex-1 flex items-end gap-[2px] h-14 cursor-pointer select-none overflow-hidden" | |
| > | |
| {Array.from({ length: NUM_BARS }, (_, i) => { | |
| // Compute height defensively — never rely on peaks array directly | |
| let v = 0.15; | |
| if (peaks && peaks[i] != null) { | |
| const p = Number(peaks[i]); | |
| if (Number.isFinite(p)) v = Math.max(0.05, Math.min(1, p)); | |
| } | |
| const prog = Number.isFinite(progress) ? progress : 0; | |
| const active = (i / NUM_BARS) < prog; | |
| const heightPct = Math.max(4, Math.min(100, v * 100)); | |
| return ( | |
| <div | |
| key={i} | |
| className="flex-1 rounded-[2px] transition-colors" | |
| style={{ | |
| height: `${heightPct}%`, | |
| background: active ? "var(--accent)" : "var(--border)", | |
| }} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| {Number.isFinite(Number(duration)) && Number(duration) > 0 && ( | |
| <div className="flex-shrink-0 text-xs font-mono" style={{ color: "var(--text-muted)" }}> | |
| {Number(duration)}s | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |