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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user