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>
  );
}