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

255
lib/ctp/render.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* CTP → WAV renderer
*
* Converts a validated CTPDocument into a WAV file (returned as a Buffer).
* Runs in a Node.js environment (no Web Audio API dependency).
*
* Click sounds:
* Beat 1 of each bar → 880 Hz (accented)
* Other beats → 440 Hz (unaccented)
* Both use a 12 ms sine wave with an exponential decay envelope.
*
* Output: 44100 Hz, 16-bit PCM, mono, WAV.
*/
import { CTPDocument, CTPSection, isRampSection, sectionStartBpm } from "./schema";
const SAMPLE_RATE = 44100;
const CLICK_DURATION_S = 0.012; // 12 ms
const ACCENT_FREQ = 880; // Hz — beat 1
const BEAT_FREQ = 440; // Hz — other beats
// ─── WAV header writer ────────────────────────────────────────────────────────
function writeWavHeader(
buf: Buffer,
numSamples: number,
numChannels = 1,
sampleRate = SAMPLE_RATE,
bitsPerSample = 16
): void {
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
const blockAlign = (numChannels * bitsPerSample) / 8;
const dataSize = numSamples * blockAlign;
let offset = 0;
const write = (s: string) => {
buf.write(s, offset, "ascii");
offset += s.length;
};
const u16 = (v: number) => {
buf.writeUInt16LE(v, offset);
offset += 2;
};
const u32 = (v: number) => {
buf.writeUInt32LE(v, offset);
offset += 4;
};
write("RIFF");
u32(36 + dataSize);
write("WAVE");
write("fmt ");
u32(16); // PCM chunk size
u16(1); // PCM format
u16(numChannels);
u32(sampleRate);
u32(byteRate);
u16(blockAlign);
u16(bitsPerSample);
write("data");
u32(dataSize);
}
// ─── Click synthesis ──────────────────────────────────────────────────────────
/**
* Writes a single click into `samples` starting at `startSample`.
* @param freq Frequency in Hz.
* @param accent True for accented (louder) click.
*/
function renderClick(
samples: Int16Array,
startSample: number,
freq: number,
accent: boolean
): void {
const clickSamples = Math.floor(CLICK_DURATION_S * SAMPLE_RATE);
const amplitude = accent ? 32000 : 22000; // peak amplitude out of 32767
const decayRate = 300; // controls how fast the envelope decays (higher = faster)
for (let i = 0; i < clickSamples; i++) {
const idx = startSample + i;
if (idx >= samples.length) break;
const t = i / SAMPLE_RATE;
const envelope = Math.exp(-decayRate * t);
const sine = Math.sin(2 * Math.PI * freq * t);
const value = Math.round(amplitude * envelope * sine);
// Mix (add + clamp) so overlapping clicks don't clip catastrophically
const existing = samples[idx];
samples[idx] = Math.max(-32767, Math.min(32767, existing + value));
}
}
// ─── Beat timing calculation ──────────────────────────────────────────────────
interface Beat {
/** Position in seconds from the start of the audio (including count-in). */
timeSeconds: number;
/** 0-based beat index within the bar (0 = beat 1 = accented). */
beatInBar: number;
}
/**
* Calculates the absolute time (seconds) of every beat in the document,
* including the count-in beats if enabled.
*/
function calculateBeats(doc: CTPDocument): Beat[] {
const beats: Beat[] = [];
const sections = doc.sections;
const firstSection = sections[0];
const firstBpm = sectionStartBpm(firstSection);
const firstNumerator = firstSection.time_signature.numerator;
let cursor = 0; // running time in seconds
// ── Count-in ──────────────────────────────────────────────────────────────
if (doc.count_in.enabled) {
const secondsPerBeat = 60 / firstBpm;
const countInBeats = doc.count_in.bars * firstNumerator;
for (let i = 0; i < countInBeats; i++) {
beats.push({ timeSeconds: cursor, beatInBar: i % firstNumerator });
cursor += secondsPerBeat;
}
}
// ── Song sections ─────────────────────────────────────────────────────────
for (let si = 0; si < sections.length; si++) {
const section = sections[si];
const nextSection = sections[si + 1] ?? null;
const { numerator } = section.time_signature;
// Number of bars in this section
const endBar = nextSection ? nextSection.start_bar : null;
if (!isRampSection(section)) {
// ── Step section ────────────────────────────────────────────────────
const secondsPerBeat = 60 / section.bpm;
if (endBar !== null) {
const bars = endBar - section.start_bar;
for (let bar = 0; bar < bars; bar++) {
for (let beat = 0; beat < numerator; beat++) {
beats.push({ timeSeconds: cursor, beatInBar: beat });
cursor += secondsPerBeat;
}
}
} else {
// Last section: generate beats until we exceed duration_seconds
const songEnd = cursor + doc.metadata.duration_seconds;
// Estimate bars remaining
const approxBarsRemaining = Math.ceil(
(doc.metadata.duration_seconds / 60) * section.bpm / numerator + 2
);
for (let bar = 0; bar < approxBarsRemaining; bar++) {
for (let beat = 0; beat < numerator; beat++) {
if (cursor > songEnd + 0.5) break;
beats.push({ timeSeconds: cursor, beatInBar: beat });
cursor += secondsPerBeat;
}
if (cursor > songEnd + 0.5) break;
}
}
} else {
// ── Ramp section ────────────────────────────────────────────────────
// We need to know the total number of beats in this section to distribute
// the ramp evenly. Compute using endBar if available, otherwise use
// duration-based estimate.
let totalBeats: number;
if (endBar !== null) {
totalBeats = (endBar - section.start_bar) * numerator;
} else {
// Last section ramp: estimate based on average BPM
const avgBpm = (section.bpm_start + section.bpm_end) / 2;
const remainingSeconds = doc.metadata.duration_seconds -
(cursor - (doc.count_in.enabled
? (doc.count_in.bars * firstNumerator * 60) / firstBpm
: 0));
totalBeats = Math.round((avgBpm / 60) * remainingSeconds);
}
// Linearly interpolate BPM beat-by-beat
for (let i = 0; i < totalBeats; i++) {
const t = totalBeats > 1 ? i / (totalBeats - 1) : 0;
const instantBpm = section.bpm_start + t * (section.bpm_end - section.bpm_start);
const secondsPerBeat = 60 / instantBpm;
beats.push({
timeSeconds: cursor,
beatInBar: i % numerator,
});
cursor += secondsPerBeat;
}
}
}
return beats;
}
// ─── Public API ───────────────────────────────────────────────────────────────
export interface RenderOptions {
/** Include count-in beats. Overrides doc.count_in.enabled when provided. */
countIn?: boolean;
}
/**
* Renders a CTPDocument to a WAV file.
* @returns A Buffer containing the complete WAV file.
*/
export function renderCTP(doc: CTPDocument, options: RenderOptions = {}): Buffer {
// Resolve effective count-in setting
const effectiveDoc: CTPDocument =
options.countIn !== undefined
? { ...doc, count_in: { ...doc.count_in, enabled: options.countIn } }
: doc;
const beats = calculateBeats(effectiveDoc);
if (beats.length === 0) {
throw new Error("CTP document produced no beats — check section data.");
}
// Total audio duration = last beat time + click decay tail
const lastBeatTime = beats[beats.length - 1].timeSeconds;
const totalSeconds = lastBeatTime + CLICK_DURATION_S + 0.1; // small tail
const totalSamples = Math.ceil(totalSeconds * SAMPLE_RATE);
// Allocate sample buffer (initialised to 0 = silence)
const samples = new Int16Array(totalSamples);
// Render each beat
for (const beat of beats) {
const startSample = Math.round(beat.timeSeconds * SAMPLE_RATE);
const isAccent = beat.beatInBar === 0;
renderClick(samples, startSample, isAccent ? ACCENT_FREQ : BEAT_FREQ, isAccent);
}
// Assemble WAV file
const WAV_HEADER_SIZE = 44;
const dataBytes = totalSamples * 2; // 16-bit = 2 bytes per sample
const wavBuffer = Buffer.allocUnsafe(WAV_HEADER_SIZE + dataBytes);
writeWavHeader(wavBuffer, totalSamples);
// Copy PCM data (little-endian 16-bit)
for (let i = 0; i < totalSamples; i++) {
wavBuffer.writeInt16LE(samples[i], WAV_HEADER_SIZE + i * 2);
}
return wavBuffer;
}

121
lib/ctp/schema.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* CTP — Click Track Protocol
*
* A JSON format for describing the tempo map of a song so that a
* metronomic click track (WAV) can be generated deterministically.
* Version: 1.0
*/
// ─── Time signature ───────────────────────────────────────────────────────────
export interface TimeSignature {
/** Beats per bar (top number). e.g. 4 */
numerator: number;
/** Note value of one beat (bottom number). e.g. 4 = quarter note */
denominator: number;
}
// ─── Section transitions ──────────────────────────────────────────────────────
/**
* "step" — tempo changes instantly at the first beat of this section.
* "ramp" — tempo interpolates linearly beat-by-beat from bpm_start to bpm_end
* over the duration of this section. Requires bpm_start + bpm_end.
*/
export type SectionTransition = "step" | "ramp";
// ─── Sections ─────────────────────────────────────────────────────────────────
/** A constant-tempo section. */
export interface StepSection {
/** Human-readable label, e.g. "Intro", "Verse 1", "Chorus". */
label: string;
/** The bar number (1-based) at which this section begins. */
start_bar: number;
/** Beats per minute for the entire section. */
bpm: number;
time_signature: TimeSignature;
transition: "step";
}
/** A section where tempo ramps linearly from bpm_start to bpm_end. */
export interface RampSection {
label: string;
start_bar: number;
/** Starting BPM (at the first beat of this section). */
bpm_start: number;
/** Ending BPM (reached at the last beat of this section, held until next). */
bpm_end: number;
time_signature: TimeSignature;
transition: "ramp";
}
export type CTPSection = StepSection | RampSection;
// ─── Count-in ─────────────────────────────────────────────────────────────────
export interface CountIn {
/** Whether to prepend a count-in before bar 1. */
enabled: boolean;
/** Number of count-in bars. Typically 1 or 2. */
bars: number;
/**
* When true, uses the BPM and time signature of the first section.
* When false (future), a separate tempo could be specified.
*/
use_first_section_tempo: boolean;
}
// ─── Metadata ─────────────────────────────────────────────────────────────────
export interface CTPMetadata {
/** Song title */
title: string;
/** Artist or band name */
artist: string;
/** MusicBrainz Recording ID (UUID). Null when MBID is unknown. */
mbid: string | null;
/** Total song duration in seconds (excluding count-in). */
duration_seconds: number;
/** Username or handle of the contributor. */
contributed_by: string;
/** Whether a human editor has verified this tempo map against the recording. */
verified: boolean;
/** ISO 8601 creation timestamp. */
created_at: string;
}
// ─── Root document ────────────────────────────────────────────────────────────
export interface CTPDocument {
/** Protocol version. Currently "1.0". */
version: "1.0";
metadata: CTPMetadata;
count_in: CountIn;
/**
* Ordered list of sections. Must contain at least one entry.
* Sections must be ordered by start_bar ascending, with no gaps.
* The first section must have start_bar === 1.
*/
sections: CTPSection[];
}
// ─── Derived helpers ──────────────────────────────────────────────────────────
/** Returns true if a section uses constant tempo (step transition). */
export function isStepSection(s: CTPSection): s is StepSection {
return s.transition === "step";
}
/** Returns true if a section ramps tempo. */
export function isRampSection(s: CTPSection): s is RampSection {
return s.transition === "ramp";
}
/**
* Returns the starting BPM of a section regardless of transition type.
* For step sections this is simply `bpm`. For ramp sections it is `bpm_start`.
*/
export function sectionStartBpm(s: CTPSection): number {
return isStepSection(s) ? s.bpm : s.bpm_start;
}

115
lib/ctp/validate.ts Normal file
View File

@@ -0,0 +1,115 @@
import { z } from "zod";
import type { CTPDocument } from "./schema";
// ─── Sub-schemas ──────────────────────────────────────────────────────────────
const TimeSignatureSchema = z.object({
numerator: z.number().int().min(1).max(32),
denominator: z
.number()
.int()
.refine((n) => [1, 2, 4, 8, 16, 32].includes(n), {
message: "denominator must be a power of 2 (1, 2, 4, 8, 16, or 32)",
}),
});
const StepSectionSchema = z.object({
label: z.string().min(1).max(64),
start_bar: z.number().int().min(1),
bpm: z.number().min(20).max(400),
time_signature: TimeSignatureSchema,
transition: z.literal("step"),
});
const RampSectionSchema = z.object({
label: z.string().min(1).max(64),
start_bar: z.number().int().min(1),
bpm_start: z.number().min(20).max(400),
bpm_end: z.number().min(20).max(400),
time_signature: TimeSignatureSchema,
transition: z.literal("ramp"),
});
const CTPSectionSchema = z.discriminatedUnion("transition", [
StepSectionSchema,
RampSectionSchema,
]);
const CountInSchema = z.object({
enabled: z.boolean(),
bars: z.number().int().min(1).max(8),
use_first_section_tempo: z.boolean(),
});
const CTPMetadataSchema = z.object({
title: z.string().min(1).max(256),
artist: z.string().min(1).max(256),
mbid: z
.string()
.uuid()
.nullable()
.default(null),
duration_seconds: z.number().positive(),
contributed_by: z.string().min(1).max(64),
verified: z.boolean(),
created_at: z.string().datetime(),
});
// ─── Root schema ──────────────────────────────────────────────────────────────
export const CTPDocumentSchema = z
.object({
version: z.literal("1.0"),
metadata: CTPMetadataSchema,
count_in: CountInSchema,
sections: z.array(CTPSectionSchema).min(1),
})
.superRefine((doc, ctx) => {
const sections = doc.sections;
// First section must start at bar 1
if (sections[0].start_bar !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sections", 0, "start_bar"],
message: "The first section must start at bar 1",
});
}
// Sections must be sorted by start_bar (strictly ascending)
for (let i = 1; i < sections.length; i++) {
if (sections[i].start_bar <= sections[i - 1].start_bar) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sections", i, "start_bar"],
message: `Section start_bar must be strictly greater than the previous section's start_bar (got ${sections[i].start_bar} after ${sections[i - 1].start_bar})`,
});
}
}
});
// ─── Exported validator ───────────────────────────────────────────────────────
export type CTPValidationResult =
| { success: true; data: CTPDocument }
| { success: false; errors: z.ZodError };
/**
* Validates an unknown value as a CTPDocument.
* Returns a typed result union instead of throwing.
*/
export function validateCTP(input: unknown): CTPValidationResult {
const result = CTPDocumentSchema.safeParse(input);
if (result.success) {
return { success: true, data: result.data as CTPDocument };
}
return { success: false, errors: result.error };
}
/**
* Validates and throws a descriptive error if the document is invalid.
* Useful in scripts/CLI contexts.
*/
export function parseCTP(input: unknown): CTPDocument {
return CTPDocumentSchema.parse(input) as CTPDocument;
}

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;
$$;

164
lib/musicbrainz/client.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* MusicBrainz API client
*
* Rate limit: 1 request per second per MusicBrainz ToS.
* https://musicbrainz.org/doc/MusicBrainz_API
*
* All responses use the JSON format (?fmt=json).
*/
const MB_BASE = "https://musicbrainz.org/ws/2";
const USER_AGENT =
process.env.MUSICBRAINZ_USER_AGENT ??
"ClickTrack/0.1 ( https://github.com/your-org/clicktrack )";
// ─── Rate limiter ─────────────────────────────────────────────────────────────
let lastRequestTime = 0;
async function rateLimitedFetch(url: string): Promise<Response> {
const now = Date.now();
const elapsed = now - lastRequestTime;
const minInterval = 1050; // slightly over 1 s to be safe
if (elapsed < minInterval) {
await new Promise((resolve) => setTimeout(resolve, minInterval - elapsed));
}
lastRequestTime = Date.now();
const response = await fetch(url, {
headers: {
"User-Agent": USER_AGENT,
Accept: "application/json",
},
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new MusicBrainzError(
`MusicBrainz API error ${response.status}: ${body}`,
response.status
);
}
return response;
}
export class MusicBrainzError extends Error {
constructor(message: string, public readonly statusCode: number) {
super(message);
this.name = "MusicBrainzError";
}
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MBRecording {
id: string; // UUID
title: string;
length?: number; // duration in milliseconds
"artist-credit": MBArtistCredit[];
releases?: MBRelease[];
score?: number; // search relevance (0100)
}
export interface MBArtistCredit {
name?: string;
artist: {
id: string;
name: string;
};
joinphrase?: string;
}
export interface MBRelease {
id: string;
title: string;
date?: string;
status?: string;
}
export interface MBSearchResult {
created: string;
count: number;
offset: number;
recordings: MBRecording[];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Returns a display string for the artist credit, e.g. "The Beatles".
*/
export function formatArtistCredit(credits: MBArtistCredit[]): string {
return credits
.map((c) => (c.name ?? c.artist.name) + (c.joinphrase ?? ""))
.join("")
.trim();
}
/**
* Converts MusicBrainz millisecond duration to seconds.
* Returns null if the value is absent.
*/
export function mbDurationToSeconds(ms: number | undefined): number | null {
return ms != null ? ms / 1000 : null;
}
// ─── API methods ──────────────────────────────────────────────────────────────
/**
* Searches for recordings matching a free-text query.
*
* @param q Free-text search string, e.g. "Bohemian Rhapsody Queen"
* @param limit Max results (1100, default 25)
* @param offset Pagination offset
*/
export async function searchRecordings(
q: string,
limit = 25,
offset = 0
): Promise<MBSearchResult> {
const params = new URLSearchParams({
query: q,
limit: String(Math.min(100, Math.max(1, limit))),
offset: String(offset),
fmt: "json",
});
const url = `${MB_BASE}/recording?${params}`;
const response = await rateLimitedFetch(url);
return response.json() as Promise<MBSearchResult>;
}
/**
* Looks up a single recording by its MusicBrainz ID.
* Includes artist-credit and release information.
*/
export async function lookupRecording(mbid: string): Promise<MBRecording> {
const params = new URLSearchParams({
inc: "artist-credits+releases",
fmt: "json",
});
const url = `${MB_BASE}/recording/${encodeURIComponent(mbid)}?${params}`;
const response = await rateLimitedFetch(url);
return response.json() as Promise<MBRecording>;
}
/**
* Convenience function: searches MusicBrainz and returns results normalised
* for storage in the `songs` table.
*/
export async function searchSongs(q: string, limit = 20) {
const result = await searchRecordings(q, limit);
return result.recordings.map((rec) => ({
mbid: rec.id,
title: rec.title,
artist: formatArtistCredit(rec["artist-credit"]),
duration_seconds: mbDurationToSeconds(rec.length),
score: rec.score ?? 0,
}));
}

176
lib/registry/sync.ts Normal file
View File

@@ -0,0 +1,176 @@
/**
* Git Registry Sync
*
* Pulls CTP files from a remote GitHub repository (the "community registry")
* and upserts them into the local database.
*
* The registry repo is expected to contain CTP JSON files at:
* <repo-root>/<artist-initial>/<artist-slug>/<recording-mbid>.ctp.json
*
* Configuration:
* REGISTRY_REPO — GitHub repo URL, e.g. https://github.com/org/clicktrack-registry
* REGISTRY_BRANCH — branch to pull from (default: main)
*/
import { execSync } from "child_process";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { validateCTP } from "../ctp/validate";
import { upsertSong, insertTempoMap, withTransaction, query } from "../db/client";
const REGISTRY_REPO = process.env.REGISTRY_REPO ?? "";
const REGISTRY_BRANCH = process.env.REGISTRY_BRANCH ?? "main";
const CLONE_DIR = join(tmpdir(), "clicktrack-registry");
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SyncResult {
recordsAdded: number;
recordsUpdated: number;
errors: Array<{ file: string; error: string }>;
status: "success" | "partial" | "failed";
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function cloneOrPull(): void {
if (existsSync(join(CLONE_DIR, ".git"))) {
execSync(`git -C "${CLONE_DIR}" fetch origin "${REGISTRY_BRANCH}" --depth=1 -q`, {
stdio: "pipe",
});
execSync(`git -C "${CLONE_DIR}" reset --hard "origin/${REGISTRY_BRANCH}" -q`, {
stdio: "pipe",
});
} else {
execSync(
`git clone --depth=1 --branch "${REGISTRY_BRANCH}" "${REGISTRY_REPO}" "${CLONE_DIR}"`,
{ stdio: "pipe" }
);
}
}
/** Recursively finds all .ctp.json files under a directory. */
function findCtpFiles(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...findCtpFiles(full));
} else if (entry.endsWith(".ctp.json")) {
results.push(full);
}
}
return results;
}
// ─── Main sync function ───────────────────────────────────────────────────────
export async function syncRegistry(): Promise<SyncResult> {
if (!REGISTRY_REPO) {
return {
recordsAdded: 0,
recordsUpdated: 0,
errors: [{ file: "", error: "REGISTRY_REPO is not configured" }],
status: "failed",
};
}
let recordsAdded = 0;
let recordsUpdated = 0;
const errors: SyncResult["errors"] = [];
try {
cloneOrPull();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await logSync(0, 0, "failed", `Git clone/pull failed: ${message}`);
return { recordsAdded: 0, recordsUpdated: 0, errors: [{ file: "", error: message }], status: "failed" };
}
const files = findCtpFiles(CLONE_DIR);
for (const filePath of files) {
let raw: unknown;
try {
raw = JSON.parse(readFileSync(filePath, "utf-8"));
} catch (err) {
errors.push({ file: filePath, error: `JSON parse error: ${err}` });
continue;
}
const validation = validateCTP(raw);
if (!validation.success) {
errors.push({
file: filePath,
error: `CTP validation failed: ${validation.errors.message}`,
});
continue;
}
const doc = validation.data;
try {
await withTransaction(async (client) => {
// Ensure the song exists in the songs table
if (doc.metadata.mbid) {
await upsertSong({
mbid: doc.metadata.mbid,
title: doc.metadata.title,
artist: doc.metadata.artist,
duration_seconds: doc.metadata.duration_seconds,
acousticbrainz_bpm: null,
acousticbrainz_time_sig_num: null,
source: "registry",
});
// Check if a matching tempo map already exists (same mbid + contributor)
const existing = await client.query(
`SELECT id FROM tempo_maps
WHERE song_mbid = $1 AND contributed_by = $2
LIMIT 1`,
[doc.metadata.mbid, doc.metadata.contributed_by]
);
if (existing.rowCount && existing.rowCount > 0) {
await client.query(
`UPDATE tempo_maps SET ctp_data = $1, updated_at = NOW()
WHERE song_mbid = $2 AND contributed_by = $3`,
[JSON.stringify(raw), doc.metadata.mbid, doc.metadata.contributed_by]
);
recordsUpdated++;
} else {
await insertTempoMap({
song_mbid: doc.metadata.mbid,
ctp_data: raw as Record<string, unknown>,
contributed_by: doc.metadata.contributed_by,
});
recordsAdded++;
}
}
});
} catch (err) {
errors.push({ file: filePath, error: `DB upsert error: ${err}` });
}
}
const status: SyncResult["status"] =
errors.length === 0 ? "success" : recordsAdded + recordsUpdated > 0 ? "partial" : "failed";
await logSync(recordsAdded, recordsUpdated, status, errors.length > 0 ? JSON.stringify(errors.slice(0, 5)) : undefined);
return { recordsAdded, recordsUpdated, errors, status };
}
async function logSync(
added: number,
updated: number,
status: string,
message?: string
): Promise<void> {
await query(
`INSERT INTO registry_sync_log (records_added, records_updated, status, message)
VALUES ($1, $2, $3, $4)`,
[added, updated, status, message ?? null]
);
}