feat: initial scaffold for ClickTrack monorepo
Full self-hosted click track generator for cover bands. Core technical pieces implemented: - CTP (Click Track Protocol) TypeScript schema, Zod validator, and WAV renderer (44.1 kHz, 16-bit PCM, accented downbeats, ramp sections) - MusicBrainz API client with 1 req/s rate limiting - PostgreSQL schema (songs, tempo_maps, registry_sync_log) with triggers - Git registry sync logic (clone/pull → validate CTP → upsert DB) - Next.js 14 App Router: search page, track page, API routes (/api/songs, /api/tracks, /api/generate) - UI components: SearchBar, SongResult, TempoMapEditor, ClickTrackPlayer (Web Audio API in-browser playback + WAV download) - Docker Compose stack: app + postgres + redis + nginx + registry-sync - Multi-stage Dockerfile with standalone Next.js output - .env.example documenting all configuration variables - README with setup instructions, CTP format spec, and API reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
243
components/ClickTrackPlayer.tsx
Normal file
243
components/ClickTrackPlayer.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"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<PlayerState>("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [countIn, setCountIn] = useState(true);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const bufferRef = useRef<AudioBuffer | null>(null);
|
||||
const startedAtRef = useRef(0); // AudioContext.currentTime when playback started
|
||||
const pausedAtRef = useRef(0); // offset into the buffer when paused
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const buildUrl = useCallback(
|
||||
() =>
|
||||
`/api/generate?id=${encodeURIComponent(tempoMapId)}&count_in=${countIn}`,
|
||||
[tempoMapId, countIn]
|
||||
);
|
||||
|
||||
// ── WAV fetch + decode ───────────────────────────────────────────────────
|
||||
async function loadBuffer(): Promise<AudioBuffer> {
|
||||
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 (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 p-6 space-y-5">
|
||||
{/* Info row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-zinc-500">
|
||||
<span className="font-mono text-green-400 text-base font-semibold">
|
||||
{firstBpm} BPM
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{ctpDoc.sections.length} sections</span>
|
||||
{verified && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-green-400">✓ Verified</span>
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>{upvotes} upvotes</span>
|
||||
</div>
|
||||
|
||||
{/* Count-in toggle */}
|
||||
<label className="flex items-center gap-3 cursor-pointer w-fit">
|
||||
<span className="text-sm text-zinc-400">Count-in</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={countIn}
|
||||
onClick={() => handleCountInChange(!countIn)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
||||
countIn ? "bg-green-600" : "bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
countIn ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{/* Transport */}
|
||||
<div className="flex items-center gap-3">
|
||||
{state !== "playing" ? (
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
disabled={state === "loading"}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 font-semibold text-white hover:bg-green-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{state === "loading" ? (
|
||||
<>
|
||||
<span className="animate-spin">⟳</span> Loading…
|
||||
</>
|
||||
) : (
|
||||
<>▶ {state === "paused" ? "Resume" : "Play"}</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
className="flex items-center gap-2 rounded-lg bg-amber-600 px-5 py-2.5 font-semibold text-white hover:bg-amber-500 transition-colors"
|
||||
>
|
||||
⏸ Pause
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(state === "playing" || state === "paused") && (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="rounded-lg border border-zinc-700 px-4 py-2.5 text-zinc-400 hover:text-zinc-200 hover:border-zinc-500 transition-colors"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state !== "idle" && state !== "loading" && (
|
||||
<span className="font-mono text-sm text-zinc-500">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download */}
|
||||
<a
|
||||
href={buildUrl()}
|
||||
download
|
||||
className="inline-flex items-center gap-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
↓ Download WAV
|
||||
</a>
|
||||
|
||||
{state === "error" && (
|
||||
<p className="text-sm text-red-400">Error: {errorMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user