Spaces:
Running
Running
File size: 5,048 Bytes
24b9788 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | 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>
);
}
|