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