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

42
components/SearchBar.tsx Normal file
View File

@@ -0,0 +1,42 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition, useState } from "react";
interface SearchBarProps {
initialValue?: string;
}
export default function SearchBar({ initialValue = "" }: SearchBarProps) {
const router = useRouter();
const [value, setValue] = useState(initialValue);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!value.trim()) return;
startTransition(() => {
router.push(`/?q=${encodeURIComponent(value.trim())}`);
});
}
return (
<form onSubmit={handleSubmit} className="flex gap-3">
<input
type="search"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search by song title or artist…"
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
autoFocus
/>
<button
type="submit"
disabled={isPending || !value.trim()}
className="rounded-lg bg-green-600 px-6 py-3 font-semibold text-white hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isPending ? "Searching…" : "Search"}
</button>
</form>
);
}

42
components/SongResult.tsx Normal file
View File

@@ -0,0 +1,42 @@
import Link from "next/link";
import type { SongRow } from "@/lib/db/client";
interface SongResultProps {
song: SongRow;
}
function formatDuration(seconds: number | null): string {
if (!seconds) return "";
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
export default function SongResult({ song }: SongResultProps) {
const duration = formatDuration(song.duration_seconds);
return (
<li>
<Link
href={`/track/${song.mbid}`}
className="flex items-center justify-between rounded-lg border border-zinc-800 bg-zinc-900 px-5 py-4 hover:border-green-600 hover:bg-zinc-800/60 transition-colors group"
>
<div className="min-w-0">
<p className="font-medium text-zinc-100 truncate group-hover:text-green-400 transition-colors">
{song.title}
</p>
<p className="text-sm text-zinc-500 truncate">{song.artist}</p>
</div>
<div className="ml-4 flex shrink-0 items-center gap-4 text-sm text-zinc-600">
{duration && <span>{duration}</span>}
{song.acousticbrainz_bpm && (
<span className="rounded bg-zinc-800 px-2 py-0.5 text-xs">
~{Math.round(Number(song.acousticbrainz_bpm))} BPM
</span>
)}
<span className="text-green-600 font-medium"></span>
</div>
</Link>
</li>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
/**
* TempoMapEditor
*
* Renders a read-only or editable view of a CTP document's sections.
* The full interactive editor (drag-to-resize bars, BPM knob, etc.)
* is a future milestone — this version is a structured table view
* with inline editing stubs.
*/
import type { CTPDocument, CTPSection } from "@/lib/ctp/schema";
import { isRampSection, sectionStartBpm } from "@/lib/ctp/schema";
interface TempoMapEditorProps {
ctpDoc: CTPDocument;
/** When true, all inputs are disabled. */
readOnly?: boolean;
onChange?: (doc: CTPDocument) => void;
}
function timeSigLabel(ts: CTPSection["time_signature"]): string {
return `${ts.numerator}/${ts.denominator}`;
}
function bpmLabel(section: CTPSection): string {
if (isRampSection(section)) {
return `${section.bpm_start}${section.bpm_end}`;
}
return String(section.bpm);
}
export default function TempoMapEditor({
ctpDoc,
readOnly = false,
}: TempoMapEditorProps) {
const { metadata, count_in, sections } = ctpDoc;
return (
<div className="space-y-5">
{/* Metadata strip */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-zinc-500">
<span>
Contributed by{" "}
<span className="text-zinc-300 font-medium">{metadata.contributed_by}</span>
</span>
{metadata.verified && (
<span className="text-green-400 font-medium"> Verified</span>
)}
<span>CTP v{ctpDoc.version}</span>
{metadata.mbid && (
<a
href={`https://musicbrainz.org/recording/${metadata.mbid}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
MusicBrainz
</a>
)}
</div>
{/* Count-in */}
<div className="flex items-center gap-3 text-sm">
<span className="text-zinc-500">Count-in:</span>
<span className="text-zinc-300">
{count_in.enabled ? `${count_in.bars} bar${count_in.bars !== 1 ? "s" : ""}` : "Disabled"}
</span>
</div>
{/* Sections table */}
<div className="overflow-x-auto rounded-lg border border-zinc-800">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-left text-xs uppercase tracking-widest text-zinc-600">
<th className="px-4 py-3">Label</th>
<th className="px-4 py-3">Start Bar</th>
<th className="px-4 py-3">BPM</th>
<th className="px-4 py-3">Time Sig</th>
<th className="px-4 py-3">Transition</th>
</tr>
</thead>
<tbody>
{sections.map((section, i) => (
<tr
key={i}
className="border-b border-zinc-800/60 last:border-0 hover:bg-zinc-800/30"
>
<td className="px-4 py-3 font-medium text-zinc-200">
{section.label}
</td>
<td className="px-4 py-3 text-zinc-400">{section.start_bar}</td>
<td className="px-4 py-3 font-mono text-green-400">
{bpmLabel(section)}
</td>
<td className="px-4 py-3 text-zinc-400">
{timeSigLabel(section.time_signature)}
</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
section.transition === "ramp"
? "bg-amber-900/40 text-amber-400"
: "bg-zinc-800 text-zinc-400"
}`}
>
{section.transition}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!readOnly && (
<p className="text-xs text-zinc-600">
Interactive editor coming soon contribute via the{" "}
<a
href="https://github.com/your-org/clicktrack-registry"
className="text-blue-500 hover:underline"
>
community registry
</a>{" "}
in the meantime.
</p>
)}
</div>
);
}