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>
165 lines
4.7 KiB
TypeScript
165 lines
4.7 KiB
TypeScript
/**
|
||
* 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,
|
||
}));
|
||
}
|