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:
AJ Avezzano
2026-04-01 11:14:46 -04:00
commit 5b772655c6
31 changed files with 2762 additions and 0 deletions

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