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

137
lib/db/client.ts Normal file
View File

@@ -0,0 +1,137 @@
import { Pool, PoolClient, QueryResult } from "pg";
// ─── Connection pool ──────────────────────────────────────────────────────────
let pool: Pool | null = null;
function getPool(): Pool {
if (!pool) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is not set.");
}
pool = new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err);
});
}
return pool;
}
// ─── Query helpers ────────────────────────────────────────────────────────────
export async function query<T = Record<string, unknown>>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return getPool().query<T>(text, params);
}
/**
* Runs a callback inside a transaction.
* Commits on success, rolls back on any thrown error.
*/
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
// ─── Song queries ─────────────────────────────────────────────────────────────
export interface SongRow {
mbid: string;
title: string;
artist: string;
duration_seconds: number | null;
acousticbrainz_bpm: number | null;
acousticbrainz_time_sig_num: number | null;
source: string;
created_at: Date;
updated_at: Date;
}
export async function searchSongs(q: string, limit = 20): Promise<SongRow[]> {
const { rows } = await query<SongRow>(
`SELECT * FROM songs
WHERE to_tsvector('english', title || ' ' || artist) @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(to_tsvector('english', title || ' ' || artist), plainto_tsquery('english', $1)) DESC
LIMIT $2`,
[q, limit]
);
return rows;
}
export async function upsertSong(song: Omit<SongRow, "created_at" | "updated_at">): Promise<void> {
await query(
`INSERT INTO songs (mbid, title, artist, duration_seconds, acousticbrainz_bpm, acousticbrainz_time_sig_num, source)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (mbid) DO UPDATE SET
title = EXCLUDED.title,
artist = EXCLUDED.artist,
duration_seconds = EXCLUDED.duration_seconds,
acousticbrainz_bpm = EXCLUDED.acousticbrainz_bpm,
acousticbrainz_time_sig_num = EXCLUDED.acousticbrainz_time_sig_num,
source = EXCLUDED.source`,
[
song.mbid,
song.title,
song.artist,
song.duration_seconds,
song.acousticbrainz_bpm,
song.acousticbrainz_time_sig_num,
song.source,
]
);
}
// ─── Tempo map queries ────────────────────────────────────────────────────────
export interface TempoMapRow {
id: string;
song_mbid: string;
ctp_data: Record<string, unknown>;
contributed_by: string;
verified: boolean;
upvotes: number;
created_at: Date;
updated_at: Date;
}
export async function getTempoMapsForSong(mbid: string): Promise<TempoMapRow[]> {
const { rows } = await query<TempoMapRow>(
`SELECT * FROM tempo_maps WHERE song_mbid = $1
ORDER BY verified DESC, upvotes DESC, created_at ASC`,
[mbid]
);
return rows;
}
export async function insertTempoMap(
map: Pick<TempoMapRow, "song_mbid" | "ctp_data" | "contributed_by">
): Promise<TempoMapRow> {
const { rows } = await query<TempoMapRow>(
`INSERT INTO tempo_maps (song_mbid, ctp_data, contributed_by)
VALUES ($1, $2, $3)
RETURNING *`,
[map.song_mbid, JSON.stringify(map.ctp_data), map.contributed_by]
);
return rows[0];
}

99
lib/db/schema.sql Normal file
View File

@@ -0,0 +1,99 @@
-- ClickTrack PostgreSQL schema
-- Run via: psql $DATABASE_URL -f lib/db/schema.sql
-- Or applied automatically by docker/init.sql on first container start.
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ─── songs ────────────────────────────────────────────────────────────────────
-- Canonical song records, populated from MusicBrainz lookups and/or
-- community contributions. mbid is the MusicBrainz Recording UUID.
CREATE TABLE IF NOT EXISTS songs (
mbid UUID PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL,
-- Duration in seconds as reported by MusicBrainz
duration_seconds NUMERIC(8, 3),
-- AcousticBrainz / AcousticBrainz-derived BPM estimate (nullable)
acousticbrainz_bpm NUMERIC(6, 2),
-- AcousticBrainz time signature numerator (e.g. 4 for 4/4)
acousticbrainz_time_sig_num INTEGER,
-- How this record was created: 'musicbrainz' | 'manual' | 'registry'
source TEXT NOT NULL DEFAULT 'musicbrainz',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS songs_title_artist_idx
ON songs USING gin(to_tsvector('english', title || ' ' || artist));
-- ─── tempo_maps ───────────────────────────────────────────────────────────────
-- Community-contributed CTP documents for a given song.
-- Multiple maps per song are allowed; clients pick the highest-voted verified one.
CREATE TABLE IF NOT EXISTS tempo_maps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
song_mbid UUID NOT NULL REFERENCES songs(mbid) ON DELETE CASCADE,
-- Full CTP document stored as JSONB for flexible querying
ctp_data JSONB NOT NULL,
contributed_by TEXT NOT NULL,
verified BOOLEAN NOT NULL DEFAULT FALSE,
upvotes INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS tempo_maps_song_mbid_idx ON tempo_maps (song_mbid);
CREATE INDEX IF NOT EXISTS tempo_maps_verified_upvotes_idx
ON tempo_maps (verified DESC, upvotes DESC);
-- Ensure ctp_data contains at least a version field
ALTER TABLE tempo_maps
ADD CONSTRAINT ctp_data_has_version
CHECK (ctp_data ? 'version');
-- ─── registry_sync_log ────────────────────────────────────────────────────────
-- Audit log for Git registry sync operations.
CREATE TABLE IF NOT EXISTS registry_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
records_added INTEGER NOT NULL DEFAULT 0,
records_updated INTEGER NOT NULL DEFAULT 0,
-- 'success' | 'partial' | 'failed'
status TEXT NOT NULL,
-- Error message or notes
message TEXT
);
-- ─── Helpers ──────────────────────────────────────────────────────────────────
-- Auto-update updated_at columns via trigger
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'songs_set_updated_at'
) THEN
CREATE TRIGGER songs_set_updated_at
BEFORE UPDATE ON songs
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'tempo_maps_set_updated_at'
) THEN
CREATE TRIGGER tempo_maps_set_updated_at
BEFORE UPDATE ON tempo_maps
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END;
$$;