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>
131 lines
4.2 KiB
TypeScript
131 lines
4.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|