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

86
app/api/generate/route.ts Normal file
View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { query } from "@/lib/db/client";
import { validateCTP } from "@/lib/ctp/validate";
import { renderCTP } from "@/lib/ctp/render";
import type { TempoMapRow } from "@/lib/db/client";
const ParamsSchema = z.object({
id: z.string().uuid("tempo map id must be a UUID"),
count_in: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional(),
});
/**
* GET /api/generate?id=<tempo-map-uuid>[&count_in=true]
*
* Renders the requested tempo map to a WAV file and streams it back.
* The response is cached for 1 hour since tempo maps are immutable once created.
*/
export async function GET(req: NextRequest) {
const raw = Object.fromEntries(req.nextUrl.searchParams);
const parsed = ParamsSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid parameters", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { id, count_in } = parsed.data;
// Load tempo map from DB
const { rows } = await query<TempoMapRow>(
"SELECT * FROM tempo_maps WHERE id = $1",
[id]
);
if (rows.length === 0) {
return NextResponse.json({ error: "Tempo map not found" }, { status: 404 });
}
const map = rows[0];
const validation = validateCTP(map.ctp_data);
if (!validation.success) {
return NextResponse.json(
{ error: "Stored CTP document is invalid", details: validation.errors.flatten() },
{ status: 500 }
);
}
const doc = validation.data;
// Render WAV
let wav: Buffer;
try {
wav = renderCTP(doc, { countIn: count_in });
} catch (err) {
console.error("[generate] Render error:", err);
return NextResponse.json(
{ error: "Failed to render click track", detail: String(err) },
{ status: 500 }
);
}
// Build a clean filename
const safeName = `${doc.metadata.artist} - ${doc.metadata.title}`
.replace(/[^\w\s\-]/g, "")
.replace(/\s+/g, "_")
.slice(0, 80);
const filename = `${safeName}_click.wav`;
return new NextResponse(wav, {
status: 200,
headers: {
"Content-Type": "audio/wav",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(wav.byteLength),
"Cache-Control": "public, max-age=3600, immutable",
},
});
}

49
app/api/songs/route.ts Normal file
View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { searchSongs as searchSongsDB, upsertSong } from "@/lib/db/client";
import { searchSongs as searchMB } from "@/lib/musicbrainz/client";
import { z } from "zod";
const QuerySchema = z.object({
q: z.string().min(1).max(200),
limit: z.coerce.number().int().min(1).max(50).default(20),
});
export async function GET(req: NextRequest) {
const params = Object.fromEntries(req.nextUrl.searchParams);
const parsed = QuerySchema.safeParse(params);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid query parameters", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { q, limit } = parsed.data;
// Try local DB first
let songs = await searchSongsDB(q, limit);
// Augment from MusicBrainz when local results are thin
if (songs.length < 3) {
try {
const mbResults = await searchMB(q, limit);
for (const s of mbResults) {
await upsertSong({
mbid: s.mbid,
title: s.title,
artist: s.artist,
duration_seconds: s.duration_seconds,
acousticbrainz_bpm: null,
acousticbrainz_time_sig_num: null,
source: "musicbrainz",
});
}
songs = await searchSongsDB(q, limit);
} catch (err) {
console.error("[songs] MusicBrainz search failed:", err);
}
}
return NextResponse.json({ songs, total: songs.length });
}

80
app/api/tracks/route.ts Normal file
View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getTempoMapsForSong, insertTempoMap, query } from "@/lib/db/client";
import { validateCTP } from "@/lib/ctp/validate";
// ─── GET /api/tracks?mbid=<uuid> ─────────────────────────────────────────────
const GetSchema = z.object({
mbid: z.string().uuid(),
});
export async function GET(req: NextRequest) {
const params = Object.fromEntries(req.nextUrl.searchParams);
const parsed = GetSchema.safeParse(params);
if (!parsed.success) {
return NextResponse.json(
{ error: "mbid (UUID) is required" },
{ status: 400 }
);
}
const maps = await getTempoMapsForSong(parsed.data.mbid);
return NextResponse.json({ maps });
}
// ─── POST /api/tracks ─────────────────────────────────────────────────────────
// Body: a raw CTP document JSON
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateCTP(body);
if (!validation.success) {
return NextResponse.json(
{
error: "CTP document validation failed",
details: validation.errors.flatten(),
},
{ status: 422 }
);
}
const doc = validation.data;
if (!doc.metadata.mbid) {
return NextResponse.json(
{ error: "CTP document must include a metadata.mbid to be stored" },
{ status: 422 }
);
}
// Ensure the song exists
const { rowCount } = await query("SELECT 1 FROM songs WHERE mbid = $1", [
doc.metadata.mbid,
]);
if (!rowCount || rowCount === 0) {
return NextResponse.json(
{
error: "Song not found. Search for the song first to register it.",
mbid: doc.metadata.mbid,
},
{ status: 404 }
);
}
const map = await insertTempoMap({
song_mbid: doc.metadata.mbid,
ctp_data: body as Record<string, unknown>,
contributed_by: doc.metadata.contributed_by,
});
return NextResponse.json({ map }, { status: 201 });
}