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>
43 lines
1.4 KiB
TypeScript
43 lines
1.4 KiB
TypeScript
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>
|
|
);
|
|
}
|