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:
44
.env.example
Normal file
44
.env.example
Normal 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
35
.gitignore
vendored
Normal 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
263
README.md
Normal 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 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=<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
101
app/(web)/page.tsx
Normal 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">“{q}”</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
app/(web)/track/[id]/page.tsx
Normal file
108
app/(web)/track/[id]/page.tsx
Normal 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
86
app/api/generate/route.ts
Normal 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
49
app/api/songs/route.ts
Normal 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
80
app/api/tracks/route.ts
Normal 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
3
app/globals.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
48
app/layout.tsx
Normal file
48
app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
components/ClickTrackPlayer.tsx
Normal file
243
components/ClickTrackPlayer.tsx
Normal 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
42
components/SearchBar.tsx
Normal 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
42
components/SongResult.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
components/TempoMapEditor.tsx
Normal file
130
components/TempoMapEditor.tsx
Normal 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
32
docker-compose.dev.yml
Normal 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
121
docker-compose.yml
Normal 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
61
docker/Dockerfile
Normal 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
75
docker/init.sql
Normal 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
38
docker/nginx.conf
Normal 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
255
lib/ctp/render.ts
Normal 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
121
lib/ctp/schema.ts
Normal 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
115
lib/ctp/validate.ts
Normal 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
137
lib/db/client.ts
Normal 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
99
lib/db/schema.sql
Normal 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
164
lib/musicbrainz/client.ts
Normal 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 (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<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
176
lib/registry/sync.ts
Normal 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
8
next.config.ts
Normal 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
34
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
23
tailwind.config.ts
Normal file
23
tailwind.config.ts
Normal 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
23
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user