"use client"; /** * ClickTrackPlayer * * In-browser playback of the click track via the Web Audio API, * plus a download button that hits /api/generate. * * Playback works by fetching the WAV from /api/generate and decoding * it into an AudioBuffer, then scheduling clicks directly rather than * streaming — WAV files for typical songs are <5 MB. */ import { useState, useRef, useCallback } from "react"; import type { CTPDocument } from "@/lib/ctp/schema"; import { sectionStartBpm } from "@/lib/ctp/schema"; interface ClickTrackPlayerProps { tempoMapId: string; ctpDoc: CTPDocument; verified: boolean; upvotes: number; } type PlayerState = "idle" | "loading" | "playing" | "paused" | "error"; export default function ClickTrackPlayer({ tempoMapId, ctpDoc, verified, upvotes, }: ClickTrackPlayerProps) { const [state, setState] = useState("idle"); const [errorMsg, setErrorMsg] = useState(""); const [countIn, setCountIn] = useState(true); const [currentTime, setCurrentTime] = useState(0); const audioCtxRef = useRef(null); const sourceRef = useRef(null); const bufferRef = useRef(null); const startedAtRef = useRef(0); // AudioContext.currentTime when playback started const pausedAtRef = useRef(0); // offset into the buffer when paused const rafRef = useRef(0); const buildUrl = useCallback( () => `/api/generate?id=${encodeURIComponent(tempoMapId)}&count_in=${countIn}`, [tempoMapId, countIn] ); // ── WAV fetch + decode ─────────────────────────────────────────────────── async function loadBuffer(): Promise { if (bufferRef.current) return bufferRef.current; const res = await fetch(buildUrl()); if (!res.ok) throw new Error(`Server error ${res.status}`); const arrayBuffer = await res.arrayBuffer(); const ctx = getAudioContext(); const decoded = await ctx.decodeAudioData(arrayBuffer); bufferRef.current = decoded; return decoded; } function getAudioContext(): AudioContext { if (!audioCtxRef.current) { audioCtxRef.current = new AudioContext(); } return audioCtxRef.current; } // ── Playback controls ──────────────────────────────────────────────────── async function handlePlay() { try { setState("loading"); const buffer = await loadBuffer(); const ctx = getAudioContext(); if (ctx.state === "suspended") await ctx.resume(); const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(ctx.destination); source.onended = () => { if (state !== "paused") { setState("idle"); pausedAtRef.current = 0; cancelAnimationFrame(rafRef.current); } }; const offset = pausedAtRef.current; source.start(0, offset); startedAtRef.current = ctx.currentTime - offset; sourceRef.current = source; setState("playing"); tickTimer(); } catch (err) { setErrorMsg(err instanceof Error ? err.message : "Unknown error"); setState("error"); } } function handlePause() { if (!sourceRef.current || !audioCtxRef.current) return; pausedAtRef.current = audioCtxRef.current.currentTime - startedAtRef.current; sourceRef.current.stop(); sourceRef.current = null; setState("paused"); cancelAnimationFrame(rafRef.current); } function handleStop() { if (sourceRef.current) { sourceRef.current.stop(); sourceRef.current = null; } pausedAtRef.current = 0; setState("idle"); setCurrentTime(0); cancelAnimationFrame(rafRef.current); } function tickTimer() { rafRef.current = requestAnimationFrame(() => { if (!audioCtxRef.current || !sourceRef.current) return; setCurrentTime(audioCtxRef.current.currentTime - startedAtRef.current); tickTimer(); }); } function formatTime(s: number): string { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); return `${m}:${String(sec).padStart(2, "0")}`; } // When count_in changes, invalidate the cached buffer so it re-fetches function handleCountInChange(v: boolean) { setCountIn(v); bufferRef.current = null; if (state !== "idle") { handleStop(); } } const firstBpm = sectionStartBpm(ctpDoc.sections[0]); return (
{/* Info row */}
{firstBpm} BPM · {ctpDoc.sections.length} sections {verified && ( <> · ✓ Verified )} · {upvotes} upvotes
{/* Count-in toggle */} {/* Transport */}
{state !== "playing" ? ( ) : ( )} {(state === "playing" || state === "paused") && ( )} {state !== "idle" && state !== "loading" && ( {formatTime(currentTime)} )}
{/* Download */} ↓ Download WAV {state === "error" && (

Error: {errorMsg}

)}
); }