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>
100 lines
4.1 KiB
PL/PgSQL
100 lines
4.1 KiB
PL/PgSQL
-- 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;
|
|
$$;
|