shreyask's picture
Initial deploy: built app at root + source under _source/
24b9788 verified
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>
);
}