commit 5b772655c60d123b540ec0f516c57c274509089e Author: AJ Avezzano Date: Wed Apr 1 11:14:46 2026 -0400 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aca5757 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1bb25e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2bda31 --- /dev/null +++ b/README.md @@ -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 1–8 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` | 1–8 | 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` | 20–400 | Only for `step` sections | +| `sections[*].bpm_start` / `bpm_end` | 20–400 | 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=&limit=` + +Search for songs. Hits local DB first, falls back to MusicBrainz. + +**Response:** +```json +{ "songs": [...], "total": 5 } +``` + +### `GET /api/tracks?mbid=` + +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=&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 diff --git a/app/(web)/page.tsx b/app/(web)/page.tsx new file mode 100644 index 0000000..b47bff1 --- /dev/null +++ b/app/(web)/page.tsx @@ -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 ( +

+ No results for “{q}”. + Try a different search. +

+ ); + } + + return ( +
    + {songs.map((song) => ( + + ))} +
+ ); +} + +export default function HomePage({ searchParams }: PageProps) { + const q = searchParams.q?.trim() ?? ""; + + return ( +
+
+

ClickTrack

+

+ Find a song, download a click track. Built for cover bands. +

+
+ + + + {q && ( + Searching…

+ } + > + {/* @ts-expect-error async server component */} + +
+ )} + + {!q && ( +
+
+
Search
+ Search any song by title or artist. We pull metadata from MusicBrainz + and cache it locally. +
+
+
Tempo Map
+ Community-contributed CTP tempo maps define every section, time + signature change, and tempo ramp in a song. +
+
+
Download
+ Generate a 44.1 kHz WAV click track with accented downbeats — ready + for your in-ear monitor mix. +
+
+ )} +
+ ); +} diff --git a/app/(web)/track/[id]/page.tsx b/app/(web)/track/[id]/page.tsx new file mode 100644 index 0000000..61c54c3 --- /dev/null +++ b/app/(web)/track/[id]/page.tsx @@ -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 { + const { rows } = await query( + "SELECT * FROM songs WHERE mbid = $1", + [mbid] + ); + return rows[0] ?? null; +} + +export async function generateMetadata({ params }: PageProps): Promise { + 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 ( +
+ {/* Song header */} +
+

Click Track

+

{song.title}

+

{song.artist}

+ {song.duration_seconds && ( +

+ {Math.floor(song.duration_seconds / 60)}m{" "} + {Math.round(song.duration_seconds % 60)}s +

+ )} +
+ + {/* Player / Download */} + {bestMap ? ( + + ) : ( +
+

No tempo map yet

+

+ Be the first to contribute a tempo map for this song via the community + registry. +

+
+ )} + + {/* Tempo map editor / viewer */} + {bestMap && ( +
+

Tempo Map

+ +
+ )} + + {/* All maps */} + {tempoMaps.length > 1 && ( +
+

+ All community maps ({tempoMaps.length}) +

+
    + {tempoMaps.map((m) => ( +
  • + + By{" "} + + {(m.ctp_data as { metadata?: { contributed_by?: string } }).metadata?.contributed_by ?? "unknown"} + + +
    + {m.verified && ( + + verified + + )} + {m.upvotes} upvotes +
    +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..8b1bb4f --- /dev/null +++ b/app/api/generate/route.ts @@ -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=[&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( + "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", + }, + }); +} diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts new file mode 100644 index 0000000..5035ebd --- /dev/null +++ b/app/api/songs/route.ts @@ -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 }); +} diff --git a/app/api/tracks/route.ts b/app/api/tracks/route.ts new file mode 100644 index 0000000..dd3b92c --- /dev/null +++ b/app/api/tracks/route.ts @@ -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= ───────────────────────────────────────────── + +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, + contributed_by: doc.metadata.contributed_by, + }); + + return NextResponse.json({ map }, { status: 201 }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..db12643 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + +
+ +
+
{children}
+
+ ClickTrack — open source, self-hosted. Tempo data from the community registry. +
+ + + ); +} diff --git a/components/ClickTrackPlayer.tsx b/components/ClickTrackPlayer.tsx new file mode 100644 index 0000000..4e97bcc --- /dev/null +++ b/components/ClickTrackPlayer.tsx @@ -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("idle"); + const [errorMsg, setErrorMsg] = useState(""); + const [countIn, setCountIn] = useState(true); + const [currentTime, setCurrentTime] = useState(0); + + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const bufferRef = useRef(null); + const startedAtRef = useRef(0); // AudioContext.currentTime when playback started + const pausedAtRef = useRef(0); // offset into the buffer when paused + const rafRef = useRef(0); + + const buildUrl = useCallback( + () => + `/api/generate?id=${encodeURIComponent(tempoMapId)}&count_in=${countIn}`, + [tempoMapId, countIn] + ); + + // ── WAV fetch + decode ─────────────────────────────────────────────────── + async function loadBuffer(): Promise { + 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 ( +
+ {/* Info row */} +
+ + {firstBpm} BPM + + · + {ctpDoc.sections.length} sections + {verified && ( + <> + · + ✓ Verified + + )} + · + {upvotes} upvotes +
+ + {/* Count-in toggle */} + + + {/* Transport */} +
+ {state !== "playing" ? ( + + ) : ( + + )} + + {(state === "playing" || state === "paused") && ( + + )} + + {state !== "idle" && state !== "loading" && ( + + {formatTime(currentTime)} + + )} +
+ + {/* Download */} + + ↓ Download WAV + + + {state === "error" && ( +

Error: {errorMsg}

+ )} +
+ ); +} diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx new file mode 100644 index 0000000..02b1fad --- /dev/null +++ b/components/SearchBar.tsx @@ -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 ( +
+ 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 + /> + +
+ ); +} diff --git a/components/SongResult.tsx b/components/SongResult.tsx new file mode 100644 index 0000000..cb2f252 --- /dev/null +++ b/components/SongResult.tsx @@ -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 ( +
  • + +
    +

    + {song.title} +

    +

    {song.artist}

    +
    +
    + {duration && {duration}} + {song.acousticbrainz_bpm && ( + + ~{Math.round(Number(song.acousticbrainz_bpm))} BPM + + )} + +
    + +
  • + ); +} diff --git a/components/TempoMapEditor.tsx b/components/TempoMapEditor.tsx new file mode 100644 index 0000000..3f1bb54 --- /dev/null +++ b/components/TempoMapEditor.tsx @@ -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 ( +
    + {/* Metadata strip */} +
    + + Contributed by{" "} + {metadata.contributed_by} + + {metadata.verified && ( + ✓ Verified + )} + CTP v{ctpDoc.version} + {metadata.mbid && ( + + MusicBrainz + + )} +
    + + {/* Count-in */} +
    + Count-in: + + {count_in.enabled ? `${count_in.bars} bar${count_in.bars !== 1 ? "s" : ""}` : "Disabled"} + +
    + + {/* Sections table */} +
    + + + + + + + + + + + + {sections.map((section, i) => ( + + + + + + + + ))} + +
    LabelStart BarBPMTime SigTransition
    + {section.label} + {section.start_bar} + {bpmLabel(section)} + + {timeSigLabel(section.time_signature)} + + + {section.transition} + +
    +
    + + {!readOnly && ( +

    + Interactive editor coming soon — contribute via the{" "} + + community registry + {" "} + in the meantime. +

    + )} +
    + ); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..b5ee819 --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d503f3f --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..9dff35f --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..fcd9aaf --- /dev/null +++ b/docker/init.sql @@ -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; +$$; diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..35982ea --- /dev/null +++ b/docker/nginx.conf @@ -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; + } +} diff --git a/lib/ctp/render.ts b/lib/ctp/render.ts new file mode 100644 index 0000000..a17a0b8 --- /dev/null +++ b/lib/ctp/render.ts @@ -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; +} diff --git a/lib/ctp/schema.ts b/lib/ctp/schema.ts new file mode 100644 index 0000000..d66e3d8 --- /dev/null +++ b/lib/ctp/schema.ts @@ -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; +} diff --git a/lib/ctp/validate.ts b/lib/ctp/validate.ts new file mode 100644 index 0000000..e286bc8 --- /dev/null +++ b/lib/ctp/validate.ts @@ -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; +} diff --git a/lib/db/client.ts b/lib/db/client.ts new file mode 100644 index 0000000..2127bba --- /dev/null +++ b/lib/db/client.ts @@ -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>( + text: string, + params?: unknown[] +): Promise> { + return getPool().query(text, params); +} + +/** + * Runs a callback inside a transaction. + * Commits on success, rolls back on any thrown error. + */ +export async function withTransaction( + fn: (client: PoolClient) => Promise +): Promise { + 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 { + const { rows } = await query( + `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): Promise { + 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; + contributed_by: string; + verified: boolean; + upvotes: number; + created_at: Date; + updated_at: Date; +} + +export async function getTempoMapsForSong(mbid: string): Promise { + const { rows } = await query( + `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 +): Promise { + const { rows } = await query( + `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]; +} diff --git a/lib/db/schema.sql b/lib/db/schema.sql new file mode 100644 index 0000000..6cfc370 --- /dev/null +++ b/lib/db/schema.sql @@ -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; +$$; diff --git a/lib/musicbrainz/client.ts b/lib/musicbrainz/client.ts new file mode 100644 index 0000000..82d135c --- /dev/null +++ b/lib/musicbrainz/client.ts @@ -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 { + 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 (0–100) +} + +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 (1–100, default 25) + * @param offset Pagination offset + */ +export async function searchRecordings( + q: string, + limit = 25, + offset = 0 +): Promise { + 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; +} + +/** + * Looks up a single recording by its MusicBrainz ID. + * Includes artist-credit and release information. + */ +export async function lookupRecording(mbid: string): Promise { + 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; +} + +/** + * 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, + })); +} diff --git a/lib/registry/sync.ts b/lib/registry/sync.ts new file mode 100644 index 0000000..8ce8bde --- /dev/null +++ b/lib/registry/sync.ts @@ -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: + * ///.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 { + 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, + 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 { + await query( + `INSERT INTO registry_sync_log (records_added, records_updated, status, message) + VALUES ($1, $2, $3, $4)`, + [added, updated, status, message ?? null] + ); +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..cdb1b59 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + serverExternalPackages: ["pg", "ioredis"], +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b87a57e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..c2096e3 --- /dev/null +++ b/tailwind.config.ts @@ -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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eea7ea5 --- /dev/null +++ b/tsconfig.json @@ -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"] +}