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

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
# ─────────────────────────────────────────────────────────────────────────────
# ClickTrack — environment configuration
# Copy this file to .env and fill in your values.
# ─────────────────────────────────────────────────────────────────────────────
# ── Database ─────────────────────────────────────────────────────────────────
# PostgreSQL connection string.
# When using docker compose the default works out of the box.
DATABASE_URL=postgres://clicktrack:clicktrack@localhost:5432/clicktrack
# Password used by the postgres service in docker-compose.yml.
# Change this before deploying to production.
POSTGRES_PASSWORD=clicktrack
# ── Redis ────────────────────────────────────────────────────────────────────
# Redis connection URL.
REDIS_URL=redis://localhost:6379
# ── Community registry ───────────────────────────────────────────────────────
# Public GitHub repository containing community CTP files.
# Example: https://github.com/your-org/clicktrack-registry
# Leave blank to disable registry sync.
REGISTRY_REPO=
# Branch to pull from (default: main).
REGISTRY_BRANCH=main
# Interval in seconds between registry syncs (default: 3600 = 1 hour).
REGISTRY_SYNC_INTERVAL=3600
# ── App ──────────────────────────────────────────────────────────────────────
# Display name shown in the UI and page title.
NEXT_PUBLIC_APP_NAME=ClickTrack
# ── MusicBrainz ──────────────────────────────────────────────────────────────
# User-Agent string sent to MusicBrainz. Must identify your application and
# provide a contact URL or email per their usage policy:
# https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
MUSICBRAINZ_USER_AGENT=ClickTrack/0.1 (https://your-instance-url)
# ── Ports (docker-compose.yml) ───────────────────────────────────────────────
# Host ports for the nginx reverse proxy.
HTTP_PORT=80
HTTPS_PORT=443

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Next.js
.next/
out/
# Environment — NEVER commit .env
.env
.env.local
.env.*.local
# Build outputs
dist/
build/
# Misc
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Docker volumes (if mounted locally)
postgres_data/
redis_data/
# Temporary registry clone
/tmp/clicktrack-registry/

263
README.md Normal file
View File

@@ -0,0 +1,263 @@
# ClickTrack
A self-hosted, open-source click track generator for cover bands.
Search any song, browse community-contributed tempo maps, and download a metronomic WAV file — accented downbeats, time signature changes, and tempo ramps included.
---
## Features
- **Song search** via MusicBrainz, cached locally in PostgreSQL
- **Community tempo maps** in the open CTP (Click Track Protocol) format
- **WAV generation** — 44.1 kHz, 16-bit PCM, mono; 880 Hz accented / 440 Hz unaccented clicks
- **Count-in** — configurable 18 bar count-in prepended before bar 1
- **Tempo ramps** — linear BPM interpolation beat-by-beat for ritardando/accelerando sections
- **Federated registry** — pull CTP files from any public Git repo on a cron schedule
- **Self-hosted** — single `docker compose up` gets you running
---
## Quick Start (Docker)
### 1. Clone and configure
```bash
git clone https://github.com/your-org/clicktrack.git
cd clicktrack
cp .env.example .env
# Edit .env — at minimum set a strong POSTGRES_PASSWORD
```
### 2. Start the stack
```bash
docker compose up -d --build
```
This starts:
| Container | Role |
|---|---|
| `app` | Next.js 14 (port 3000, proxied via nginx) |
| `postgres` | PostgreSQL 16 with persistent volume |
| `redis` | Redis 7 for caching |
| `nginx` | Reverse proxy on ports 80/443 |
### 3. Open the app
Navigate to `http://localhost` (or your server's IP/domain).
### 4. Enable registry sync (optional)
Set `REGISTRY_REPO` in `.env` to a public GitHub repo URL containing CTP files, then:
```bash
docker compose --profile registry up -d
```
The `registry-sync` container will pull and import CTP files every `REGISTRY_SYNC_INTERVAL` seconds (default: 1 hour).
---
## Development Setup
```bash
# Prerequisites: Node 20+, a local PostgreSQL 16, Redis 7
cp .env.example .env
# Update DATABASE_URL and REDIS_URL to point at your local services
npm install
# Apply schema
psql $DATABASE_URL -f lib/db/schema.sql
# Start dev server with hot reload
npm run dev
```
Or with Docker (hot-reload via volume mount):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
---
## CTP — Click Track Protocol
CTP is an open JSON format for describing a song's complete tempo map. A CTP file is a plain `.ctp.json` file that can be version-controlled and shared.
### Full example
```json
{
"version": "1.0",
"metadata": {
"title": "Bohemian Rhapsody",
"artist": "Queen",
"mbid": "b1a9c0e0-9c4a-4a6b-8b5a-1234567890ab",
"duration_seconds": 354,
"contributed_by": "freddie_fan",
"verified": false,
"created_at": "2026-04-01T00:00:00Z"
},
"count_in": {
"enabled": true,
"bars": 1,
"use_first_section_tempo": true
},
"sections": [
{
"label": "Intro (Ballad)",
"start_bar": 1,
"bpm": 72.0,
"time_signature": { "numerator": 4, "denominator": 4 },
"transition": "step"
},
{
"label": "Rock Section",
"start_bar": 45,
"bpm": 152.0,
"time_signature": { "numerator": 4, "denominator": 4 },
"transition": "step"
},
{
"label": "Outro (slowing)",
"start_bar": 120,
"bpm_start": 152.0,
"bpm_end": 72.0,
"time_signature": { "numerator": 4, "denominator": 4 },
"transition": "ramp"
}
]
}
```
### Schema rules
| Field | Type | Notes |
|---|---|---|
| `version` | `"1.0"` | Must be exactly `"1.0"` |
| `metadata.mbid` | UUID or `null` | MusicBrainz Recording ID |
| `metadata.duration_seconds` | number | Total song duration (excluding count-in) |
| `count_in.bars` | 18 | Number of count-in bars |
| `sections[*].start_bar` | integer ≥ 1 | Must start at 1, strictly ascending |
| `sections[*].transition` | `"step"` \| `"ramp"` | Step = instant change; ramp = linear interpolation |
| `sections[*].bpm` | 20400 | Only for `step` sections |
| `sections[*].bpm_start` / `bpm_end` | 20400 | Only for `ramp` sections |
| `sections[*].time_signature.denominator` | 1, 2, 4, 8, 16, 32 | Must be a power of 2 |
### Click sounds
| Beat | Frequency | Amplitude |
|---|---|---|
| Beat 1 (downbeat) | 880 Hz | Accented |
| Other beats | 440 Hz | Normal |
Both use a 12 ms sine wave with exponential decay (`e^(-300t)`).
---
## Community Registry
The registry is a public Git repository containing `.ctp.json` files organised by artist:
```
registry-repo/
q/
queen/
b1a9c0e0-9c4a-4a6b-8b5a-1234567890ab.ctp.json # Bohemian Rhapsody
t/
the-beatles/
...
```
### Contributing a tempo map
1. Fork the community registry repo.
2. Create a `.ctp.json` file for your song. Use the schema above.
3. Validate your file: paste the JSON into a CTP validator (coming soon) or use the API:
```bash
curl -X POST http://localhost/api/tracks \
-H "Content-Type: application/json" \
-d @your-song.ctp.json
```
4. Open a pull request to the registry repo.
Once merged, any ClickTrack instance syncing that registry will import your map automatically.
---
## API Reference
### `GET /api/songs?q=<query>&limit=<n>`
Search for songs. Hits local DB first, falls back to MusicBrainz.
**Response:**
```json
{ "songs": [...], "total": 5 }
```
### `GET /api/tracks?mbid=<uuid>`
List all community tempo maps for a song.
### `POST /api/tracks`
Submit a new CTP document. Body must be a valid CTP JSON.
**Response:** `201 Created` with the stored map record.
### `GET /api/generate?id=<tempo-map-uuid>&count_in=true`
Generate and download a WAV click track.
- `id` — UUID of the tempo map (from `/api/tracks`)
- `count_in` — `true` or `false` (default: `true`)
Returns `audio/wav` with `Content-Disposition: attachment`.
---
## Architecture
```
Browser
└── Next.js App Router (app/)
├── (web)/page.tsx — song search
├── (web)/track/[id]/ — track page + player
└── api/
├── songs/ — search + MB integration
├── tracks/ — CTP CRUD
└── generate/ — WAV rendering
lib/
├── ctp/
│ ├── schema.ts — TypeScript types
│ ├── validate.ts — Zod validation
│ └── render.ts — CTP → WAV (Node.js)
├── db/client.ts — pg Pool + query helpers
├── musicbrainz/client.ts — rate-limited MB API
└── registry/sync.ts — Git registry pull + upsert
```
---
## Environment Variables
See [`.env.example`](.env.example) for all variables with descriptions.
| Variable | Required | Default | Description |
|---|---|---|---|
| `DATABASE_URL` | Yes | — | PostgreSQL connection string |
| `REDIS_URL` | Yes | — | Redis connection URL |
| `NEXT_PUBLIC_APP_NAME` | No | `ClickTrack` | UI display name |
| `REGISTRY_REPO` | No | — | GitHub repo URL for CTP registry |
| `REGISTRY_SYNC_INTERVAL` | No | `3600` | Sync interval in seconds |
| `MUSICBRAINZ_USER_AGENT` | No | built-in | User-Agent for MB API requests |
---
## License
MIT

101
app/(web)/page.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { Suspense } from "react";
import SearchBar from "@/components/SearchBar";
import SongResult from "@/components/SongResult";
import { searchSongs as searchSongsDB } from "@/lib/db/client";
import { searchSongs as searchMB } from "@/lib/musicbrainz/client";
import { upsertSong } from "@/lib/db/client";
interface PageProps {
searchParams: { q?: string };
}
async function SearchResults({ q }: { q: string }) {
// Try local DB first
let songs = await searchSongsDB(q, 20);
// If sparse results, hit MusicBrainz and cache locally
if (songs.length < 3) {
try {
const mbResults = await searchMB(q, 20);
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, 20);
} catch {
// MusicBrainz unavailable — fall through with local results
}
}
if (songs.length === 0) {
return (
<p className="mt-8 text-center text-zinc-500">
No results for <span className="text-zinc-300">&ldquo;{q}&rdquo;</span>.
Try a different search.
</p>
);
}
return (
<ul className="mt-6 space-y-3">
{songs.map((song) => (
<SongResult key={song.mbid} song={song} />
))}
</ul>
);
}
export default function HomePage({ searchParams }: PageProps) {
const q = searchParams.q?.trim() ?? "";
return (
<div>
<div className="mb-10 text-center">
<h1 className="text-4xl font-bold tracking-tight text-green-400">ClickTrack</h1>
<p className="mt-3 text-zinc-400">
Find a song, download a click track. Built for cover bands.
</p>
</div>
<SearchBar initialValue={q} />
{q && (
<Suspense
fallback={
<p className="mt-8 text-center text-zinc-500 animate-pulse">Searching</p>
}
>
{/* @ts-expect-error async server component */}
<SearchResults q={q} />
</Suspense>
)}
{!q && (
<div className="mt-12 grid gap-6 text-sm text-zinc-500 sm:grid-cols-3">
<div className="rounded-lg border border-zinc-800 p-5">
<div className="mb-2 text-lg font-semibold text-zinc-200">Search</div>
Search any song by title or artist. We pull metadata from MusicBrainz
and cache it locally.
</div>
<div className="rounded-lg border border-zinc-800 p-5">
<div className="mb-2 text-lg font-semibold text-zinc-200">Tempo Map</div>
Community-contributed CTP tempo maps define every section, time
signature change, and tempo ramp in a song.
</div>
<div className="rounded-lg border border-zinc-800 p-5">
<div className="mb-2 text-lg font-semibold text-zinc-200">Download</div>
Generate a 44.1 kHz WAV click track with accented downbeats ready
for your in-ear monitor mix.
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { query, getTempoMapsForSong } from "@/lib/db/client";
import type { SongRow } from "@/lib/db/client";
import TempoMapEditor from "@/components/TempoMapEditor";
import ClickTrackPlayer from "@/components/ClickTrackPlayer";
import type { CTPDocument } from "@/lib/ctp/schema";
interface PageProps {
params: { id: string };
}
async function getSong(mbid: string): Promise<SongRow | null> {
const { rows } = await query<SongRow>(
"SELECT * FROM songs WHERE mbid = $1",
[mbid]
);
return rows[0] ?? null;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const song = await getSong(params.id);
if (!song) return { title: "Track not found" };
return { title: `${song.title}${song.artist}` };
}
export default async function TrackPage({ params }: PageProps) {
const song = await getSong(params.id);
if (!song) notFound();
const tempoMaps = await getTempoMapsForSong(params.id);
const bestMap = tempoMaps.find((m) => m.verified) ?? tempoMaps[0] ?? null;
return (
<div className="space-y-8">
{/* Song header */}
<div>
<p className="text-sm text-zinc-500 uppercase tracking-widest mb-1">Click Track</p>
<h1 className="text-3xl font-bold">{song.title}</h1>
<p className="mt-1 text-lg text-zinc-400">{song.artist}</p>
{song.duration_seconds && (
<p className="mt-1 text-sm text-zinc-600">
{Math.floor(song.duration_seconds / 60)}m{" "}
{Math.round(song.duration_seconds % 60)}s
</p>
)}
</div>
{/* Player / Download */}
{bestMap ? (
<ClickTrackPlayer
tempoMapId={bestMap.id}
ctpDoc={bestMap.ctp_data as unknown as CTPDocument}
verified={bestMap.verified}
upvotes={bestMap.upvotes}
/>
) : (
<div className="rounded-lg border border-dashed border-zinc-700 p-10 text-center text-zinc-500">
<p className="text-lg font-medium text-zinc-300 mb-2">No tempo map yet</p>
<p className="text-sm">
Be the first to contribute a tempo map for this song via the community
registry.
</p>
</div>
)}
{/* Tempo map editor / viewer */}
{bestMap && (
<div>
<h2 className="text-lg font-semibold mb-4">Tempo Map</h2>
<TempoMapEditor ctpDoc={bestMap.ctp_data as unknown as CTPDocument} readOnly />
</div>
)}
{/* All maps */}
{tempoMaps.length > 1 && (
<div>
<h2 className="text-lg font-semibold mb-3">
All community maps ({tempoMaps.length})
</h2>
<ul className="space-y-2">
{tempoMaps.map((m) => (
<li
key={m.id}
className="flex items-center justify-between rounded-lg border border-zinc-800 px-4 py-3 text-sm"
>
<span className="text-zinc-300">
By{" "}
<span className="font-medium">
{(m.ctp_data as { metadata?: { contributed_by?: string } }).metadata?.contributed_by ?? "unknown"}
</span>
</span>
<div className="flex items-center gap-3 text-zinc-500">
{m.verified && (
<span className="rounded-full bg-green-900/40 px-2 py-0.5 text-xs text-green-400">
verified
</span>
)}
<span>{m.upvotes} upvotes</span>
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}

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

3
app/globals.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

48
app/layout.tsx Normal file
View File

@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: process.env.NEXT_PUBLIC_APP_NAME ?? "ClickTrack",
template: `%s | ${process.env.NEXT_PUBLIC_APP_NAME ?? "ClickTrack"}`,
},
description:
"Self-hosted click track generator for cover bands. Search songs, view community tempo maps, and download metronomic WAV files.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="min-h-screen bg-zinc-950 text-zinc-100 antialiased">
<header className="border-b border-zinc-800 px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<a href="/" className="text-xl font-bold tracking-tight text-green-400">
{process.env.NEXT_PUBLIC_APP_NAME ?? "ClickTrack"}
</a>
<nav className="flex gap-6 text-sm text-zinc-400">
<a href="/" className="hover:text-zinc-100 transition-colors">
Search
</a>
<a
href="https://github.com/your-org/clicktrack"
target="_blank"
rel="noopener noreferrer"
className="hover:text-zinc-100 transition-colors"
>
GitHub
</a>
</nav>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
<footer className="border-t border-zinc-800 px-6 py-6 text-center text-xs text-zinc-600">
ClickTrack open source, self-hosted. Tempo data from the community registry.
</footer>
</body>
</html>
);
}

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

32
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "3.9"
# Development overrides
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
app:
build:
target: deps # stop before the production build step
command: npm run dev
volumes:
# Mount source for hot reload — node_modules stays inside the container
- .:/app
- /app/node_modules
- /app/.next
environment:
NODE_ENV: development
ports:
- "3000:3000" # expose directly in dev (no nginx needed)
postgres:
ports:
- "5432:5432" # expose for local DB tools (TablePlus, psql, etc.)
redis:
ports:
- "6379:6379" # expose for local Redis inspection
# Disable nginx in dev
nginx:
profiles:
- production-only

121
docker-compose.yml Normal file
View File

@@ -0,0 +1,121 @@
version: "3.9"
# ClickTrack — full self-hosted stack
# Usage: docker compose up -d
# First run: docker compose up -d --build
services:
# ── Next.js application ──────────────────────────────────────────────────────
app:
build:
context: .
dockerfile: docker/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
DATABASE_URL: postgres://clicktrack:${POSTGRES_PASSWORD:-clicktrack}@postgres:5432/clicktrack
REDIS_URL: redis://redis:6379
REGISTRY_REPO: ${REGISTRY_REPO:-}
REGISTRY_BRANCH: ${REGISTRY_BRANCH:-main}
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME:-ClickTrack}
MUSICBRAINZ_USER_AGENT: ${MUSICBRAINZ_USER_AGENT:-ClickTrack/0.1 (self-hosted)}
NODE_ENV: production
expose:
- "3000"
networks:
- internal
# ── PostgreSQL 16 ─────────────────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: clicktrack
POSTGRES_USER: clicktrack
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-clicktrack}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U clicktrack -d clicktrack"]
interval: 5s
timeout: 5s
retries: 10
networks:
- internal
# ── Redis 7 ───────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
networks:
- internal
# ── Nginx reverse proxy ───────────────────────────────────────────────────────
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
- app
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
# Mount TLS certificates here for HTTPS (optional):
# - /etc/letsencrypt:/etc/letsencrypt:ro
networks:
- internal
# ── Registry sync (optional cron container) ───────────────────────────────────
registry-sync:
build:
context: .
dockerfile: docker/Dockerfile
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgres://clicktrack:${POSTGRES_PASSWORD:-clicktrack}@postgres:5432/clicktrack
REDIS_URL: redis://redis:6379
REGISTRY_REPO: ${REGISTRY_REPO:-}
REGISTRY_BRANCH: ${REGISTRY_BRANCH:-main}
NODE_ENV: production
# Run the sync script on a cron schedule inside the container.
# Requires REGISTRY_REPO to be set; the container exits cleanly if not.
command: >
sh -c '
while true; do
node -e "
const { syncRegistry } = require(\"./lib/registry/sync\");
syncRegistry().then(r => console.log(\"Sync:\", JSON.stringify(r))).catch(console.error);
" || true;
sleep ${REGISTRY_SYNC_INTERVAL:-3600};
done
'
profiles:
- registry
networks:
- internal
volumes:
postgres_data:
redis_data:
networks:
internal:
driver: bridge

61
docker/Dockerfile Normal file
View File

@@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1
# ────────────────────────────────────────────────────────────────────────────────
# Stage 1 — deps: install all node_modules
# ────────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat git
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN \
if [ -f package-lock.json ]; then npm ci; \
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
else echo "No lockfile found." && npm install; \
fi
# ────────────────────────────────────────────────────────────────────────────────
# Stage 2 — builder: compile Next.js production build
# ────────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ────────────────────────────────────────────────────────────────────────────────
# Stage 3 — runner: minimal production image
# ────────────────────────────────────────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Copy only what's needed to run
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

75
docker/init.sql Normal file
View File

@@ -0,0 +1,75 @@
-- init.sql
-- Executed by PostgreSQL on first container start.
-- The full schema is copied here so it runs automatically via
-- docker-entrypoint-initdb.d without requiring a separate psql call.
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ─── songs ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS songs (
mbid UUID PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_seconds NUMERIC(8, 3),
acousticbrainz_bpm NUMERIC(6, 2),
acousticbrainz_time_sig_num INTEGER,
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 ───────────────────────────────────────────────────────────────
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,
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);
ALTER TABLE tempo_maps
ADD CONSTRAINT ctp_data_has_version
CHECK (ctp_data ? 'version');
-- ─── registry_sync_log ────────────────────────────────────────────────────────
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,
status TEXT NOT NULL,
message TEXT
);
-- ─── Triggers ─────────────────────────────────────────────────────────────────
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;
$$;

38
docker/nginx.conf Normal file
View File

@@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
# Increase buffer for WAV downloads
proxy_buffers 16 64k;
proxy_buffer_size 128k;
proxy_read_timeout 120s;
# Gzip (skip audio/wav — already binary)
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Cache static Next.js assets aggressively
location /_next/static/ {
proxy_pass http://app:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# WAV downloads — no nginx caching (app sets its own Cache-Control)
location /api/generate {
proxy_pass http://app:3000;
proxy_buffering off;
proxy_read_timeout 120s;
}
}

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

8
next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["pg", "ioredis"],
};
export default nextConfig;

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "clicktrack",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"db:migrate": "psql $DATABASE_URL -f lib/db/schema.sql"
},
"dependencies": {
"next": "14.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod": "^3.23.8",
"pg": "^8.11.5",
"ioredis": "^5.3.2",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@types/node": "^20.12.7",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/pg": "^8.11.5",
"typescript": "^5.4.5",
"tailwindcss": "^3.4.3",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.3"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

23
tailwind.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
50: "#f0fdf4",
500: "#22c55e",
900: "#14532d",
},
},
},
},
plugins: [],
};
export default config;

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}