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:
255
lib/ctp/render.ts
Normal file
255
lib/ctp/render.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* CTP → WAV renderer
|
||||
*
|
||||
* Converts a validated CTPDocument into a WAV file (returned as a Buffer).
|
||||
* Runs in a Node.js environment (no Web Audio API dependency).
|
||||
*
|
||||
* Click sounds:
|
||||
* Beat 1 of each bar → 880 Hz (accented)
|
||||
* Other beats → 440 Hz (unaccented)
|
||||
* Both use a 12 ms sine wave with an exponential decay envelope.
|
||||
*
|
||||
* Output: 44100 Hz, 16-bit PCM, mono, WAV.
|
||||
*/
|
||||
|
||||
import { CTPDocument, CTPSection, isRampSection, sectionStartBpm } from "./schema";
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const CLICK_DURATION_S = 0.012; // 12 ms
|
||||
const ACCENT_FREQ = 880; // Hz — beat 1
|
||||
const BEAT_FREQ = 440; // Hz — other beats
|
||||
|
||||
// ─── WAV header writer ────────────────────────────────────────────────────────
|
||||
|
||||
function writeWavHeader(
|
||||
buf: Buffer,
|
||||
numSamples: number,
|
||||
numChannels = 1,
|
||||
sampleRate = SAMPLE_RATE,
|
||||
bitsPerSample = 16
|
||||
): void {
|
||||
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
||||
const blockAlign = (numChannels * bitsPerSample) / 8;
|
||||
const dataSize = numSamples * blockAlign;
|
||||
|
||||
let offset = 0;
|
||||
const write = (s: string) => {
|
||||
buf.write(s, offset, "ascii");
|
||||
offset += s.length;
|
||||
};
|
||||
const u16 = (v: number) => {
|
||||
buf.writeUInt16LE(v, offset);
|
||||
offset += 2;
|
||||
};
|
||||
const u32 = (v: number) => {
|
||||
buf.writeUInt32LE(v, offset);
|
||||
offset += 4;
|
||||
};
|
||||
|
||||
write("RIFF");
|
||||
u32(36 + dataSize);
|
||||
write("WAVE");
|
||||
write("fmt ");
|
||||
u32(16); // PCM chunk size
|
||||
u16(1); // PCM format
|
||||
u16(numChannels);
|
||||
u32(sampleRate);
|
||||
u32(byteRate);
|
||||
u16(blockAlign);
|
||||
u16(bitsPerSample);
|
||||
write("data");
|
||||
u32(dataSize);
|
||||
}
|
||||
|
||||
// ─── Click synthesis ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Writes a single click into `samples` starting at `startSample`.
|
||||
* @param freq Frequency in Hz.
|
||||
* @param accent True for accented (louder) click.
|
||||
*/
|
||||
function renderClick(
|
||||
samples: Int16Array,
|
||||
startSample: number,
|
||||
freq: number,
|
||||
accent: boolean
|
||||
): void {
|
||||
const clickSamples = Math.floor(CLICK_DURATION_S * SAMPLE_RATE);
|
||||
const amplitude = accent ? 32000 : 22000; // peak amplitude out of 32767
|
||||
const decayRate = 300; // controls how fast the envelope decays (higher = faster)
|
||||
|
||||
for (let i = 0; i < clickSamples; i++) {
|
||||
const idx = startSample + i;
|
||||
if (idx >= samples.length) break;
|
||||
|
||||
const t = i / SAMPLE_RATE;
|
||||
const envelope = Math.exp(-decayRate * t);
|
||||
const sine = Math.sin(2 * Math.PI * freq * t);
|
||||
const value = Math.round(amplitude * envelope * sine);
|
||||
|
||||
// Mix (add + clamp) so overlapping clicks don't clip catastrophically
|
||||
const existing = samples[idx];
|
||||
samples[idx] = Math.max(-32767, Math.min(32767, existing + value));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Beat timing calculation ──────────────────────────────────────────────────
|
||||
|
||||
interface Beat {
|
||||
/** Position in seconds from the start of the audio (including count-in). */
|
||||
timeSeconds: number;
|
||||
/** 0-based beat index within the bar (0 = beat 1 = accented). */
|
||||
beatInBar: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the absolute time (seconds) of every beat in the document,
|
||||
* including the count-in beats if enabled.
|
||||
*/
|
||||
function calculateBeats(doc: CTPDocument): Beat[] {
|
||||
const beats: Beat[] = [];
|
||||
|
||||
const sections = doc.sections;
|
||||
const firstSection = sections[0];
|
||||
const firstBpm = sectionStartBpm(firstSection);
|
||||
const firstNumerator = firstSection.time_signature.numerator;
|
||||
|
||||
let cursor = 0; // running time in seconds
|
||||
|
||||
// ── Count-in ──────────────────────────────────────────────────────────────
|
||||
if (doc.count_in.enabled) {
|
||||
const secondsPerBeat = 60 / firstBpm;
|
||||
const countInBeats = doc.count_in.bars * firstNumerator;
|
||||
for (let i = 0; i < countInBeats; i++) {
|
||||
beats.push({ timeSeconds: cursor, beatInBar: i % firstNumerator });
|
||||
cursor += secondsPerBeat;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Song sections ─────────────────────────────────────────────────────────
|
||||
for (let si = 0; si < sections.length; si++) {
|
||||
const section = sections[si];
|
||||
const nextSection = sections[si + 1] ?? null;
|
||||
const { numerator } = section.time_signature;
|
||||
|
||||
// Number of bars in this section
|
||||
const endBar = nextSection ? nextSection.start_bar : null;
|
||||
|
||||
if (!isRampSection(section)) {
|
||||
// ── Step section ────────────────────────────────────────────────────
|
||||
const secondsPerBeat = 60 / section.bpm;
|
||||
|
||||
if (endBar !== null) {
|
||||
const bars = endBar - section.start_bar;
|
||||
for (let bar = 0; bar < bars; bar++) {
|
||||
for (let beat = 0; beat < numerator; beat++) {
|
||||
beats.push({ timeSeconds: cursor, beatInBar: beat });
|
||||
cursor += secondsPerBeat;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Last section: generate beats until we exceed duration_seconds
|
||||
const songEnd = cursor + doc.metadata.duration_seconds;
|
||||
// Estimate bars remaining
|
||||
const approxBarsRemaining = Math.ceil(
|
||||
(doc.metadata.duration_seconds / 60) * section.bpm / numerator + 2
|
||||
);
|
||||
for (let bar = 0; bar < approxBarsRemaining; bar++) {
|
||||
for (let beat = 0; beat < numerator; beat++) {
|
||||
if (cursor > songEnd + 0.5) break;
|
||||
beats.push({ timeSeconds: cursor, beatInBar: beat });
|
||||
cursor += secondsPerBeat;
|
||||
}
|
||||
if (cursor > songEnd + 0.5) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Ramp section ────────────────────────────────────────────────────
|
||||
// We need to know the total number of beats in this section to distribute
|
||||
// the ramp evenly. Compute using endBar if available, otherwise use
|
||||
// duration-based estimate.
|
||||
|
||||
let totalBeats: number;
|
||||
|
||||
if (endBar !== null) {
|
||||
totalBeats = (endBar - section.start_bar) * numerator;
|
||||
} else {
|
||||
// Last section ramp: estimate based on average BPM
|
||||
const avgBpm = (section.bpm_start + section.bpm_end) / 2;
|
||||
const remainingSeconds = doc.metadata.duration_seconds -
|
||||
(cursor - (doc.count_in.enabled
|
||||
? (doc.count_in.bars * firstNumerator * 60) / firstBpm
|
||||
: 0));
|
||||
totalBeats = Math.round((avgBpm / 60) * remainingSeconds);
|
||||
}
|
||||
|
||||
// Linearly interpolate BPM beat-by-beat
|
||||
for (let i = 0; i < totalBeats; i++) {
|
||||
const t = totalBeats > 1 ? i / (totalBeats - 1) : 0;
|
||||
const instantBpm = section.bpm_start + t * (section.bpm_end - section.bpm_start);
|
||||
const secondsPerBeat = 60 / instantBpm;
|
||||
beats.push({
|
||||
timeSeconds: cursor,
|
||||
beatInBar: i % numerator,
|
||||
});
|
||||
cursor += secondsPerBeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return beats;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Include count-in beats. Overrides doc.count_in.enabled when provided. */
|
||||
countIn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a CTPDocument to a WAV file.
|
||||
* @returns A Buffer containing the complete WAV file.
|
||||
*/
|
||||
export function renderCTP(doc: CTPDocument, options: RenderOptions = {}): Buffer {
|
||||
// Resolve effective count-in setting
|
||||
const effectiveDoc: CTPDocument =
|
||||
options.countIn !== undefined
|
||||
? { ...doc, count_in: { ...doc.count_in, enabled: options.countIn } }
|
||||
: doc;
|
||||
|
||||
const beats = calculateBeats(effectiveDoc);
|
||||
|
||||
if (beats.length === 0) {
|
||||
throw new Error("CTP document produced no beats — check section data.");
|
||||
}
|
||||
|
||||
// Total audio duration = last beat time + click decay tail
|
||||
const lastBeatTime = beats[beats.length - 1].timeSeconds;
|
||||
const totalSeconds = lastBeatTime + CLICK_DURATION_S + 0.1; // small tail
|
||||
const totalSamples = Math.ceil(totalSeconds * SAMPLE_RATE);
|
||||
|
||||
// Allocate sample buffer (initialised to 0 = silence)
|
||||
const samples = new Int16Array(totalSamples);
|
||||
|
||||
// Render each beat
|
||||
for (const beat of beats) {
|
||||
const startSample = Math.round(beat.timeSeconds * SAMPLE_RATE);
|
||||
const isAccent = beat.beatInBar === 0;
|
||||
renderClick(samples, startSample, isAccent ? ACCENT_FREQ : BEAT_FREQ, isAccent);
|
||||
}
|
||||
|
||||
// Assemble WAV file
|
||||
const WAV_HEADER_SIZE = 44;
|
||||
const dataBytes = totalSamples * 2; // 16-bit = 2 bytes per sample
|
||||
const wavBuffer = Buffer.allocUnsafe(WAV_HEADER_SIZE + dataBytes);
|
||||
|
||||
writeWavHeader(wavBuffer, totalSamples);
|
||||
|
||||
// Copy PCM data (little-endian 16-bit)
|
||||
for (let i = 0; i < totalSamples; i++) {
|
||||
wavBuffer.writeInt16LE(samples[i], WAV_HEADER_SIZE + i * 2);
|
||||
}
|
||||
|
||||
return wavBuffer;
|
||||
}
|
||||
121
lib/ctp/schema.ts
Normal file
121
lib/ctp/schema.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* CTP — Click Track Protocol
|
||||
*
|
||||
* A JSON format for describing the tempo map of a song so that a
|
||||
* metronomic click track (WAV) can be generated deterministically.
|
||||
* Version: 1.0
|
||||
*/
|
||||
|
||||
// ─── Time signature ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimeSignature {
|
||||
/** Beats per bar (top number). e.g. 4 */
|
||||
numerator: number;
|
||||
/** Note value of one beat (bottom number). e.g. 4 = quarter note */
|
||||
denominator: number;
|
||||
}
|
||||
|
||||
// ─── Section transitions ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* "step" — tempo changes instantly at the first beat of this section.
|
||||
* "ramp" — tempo interpolates linearly beat-by-beat from bpm_start to bpm_end
|
||||
* over the duration of this section. Requires bpm_start + bpm_end.
|
||||
*/
|
||||
export type SectionTransition = "step" | "ramp";
|
||||
|
||||
// ─── Sections ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A constant-tempo section. */
|
||||
export interface StepSection {
|
||||
/** Human-readable label, e.g. "Intro", "Verse 1", "Chorus". */
|
||||
label: string;
|
||||
/** The bar number (1-based) at which this section begins. */
|
||||
start_bar: number;
|
||||
/** Beats per minute for the entire section. */
|
||||
bpm: number;
|
||||
time_signature: TimeSignature;
|
||||
transition: "step";
|
||||
}
|
||||
|
||||
/** A section where tempo ramps linearly from bpm_start to bpm_end. */
|
||||
export interface RampSection {
|
||||
label: string;
|
||||
start_bar: number;
|
||||
/** Starting BPM (at the first beat of this section). */
|
||||
bpm_start: number;
|
||||
/** Ending BPM (reached at the last beat of this section, held until next). */
|
||||
bpm_end: number;
|
||||
time_signature: TimeSignature;
|
||||
transition: "ramp";
|
||||
}
|
||||
|
||||
export type CTPSection = StepSection | RampSection;
|
||||
|
||||
// ─── Count-in ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CountIn {
|
||||
/** Whether to prepend a count-in before bar 1. */
|
||||
enabled: boolean;
|
||||
/** Number of count-in bars. Typically 1 or 2. */
|
||||
bars: number;
|
||||
/**
|
||||
* When true, uses the BPM and time signature of the first section.
|
||||
* When false (future), a separate tempo could be specified.
|
||||
*/
|
||||
use_first_section_tempo: boolean;
|
||||
}
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CTPMetadata {
|
||||
/** Song title */
|
||||
title: string;
|
||||
/** Artist or band name */
|
||||
artist: string;
|
||||
/** MusicBrainz Recording ID (UUID). Null when MBID is unknown. */
|
||||
mbid: string | null;
|
||||
/** Total song duration in seconds (excluding count-in). */
|
||||
duration_seconds: number;
|
||||
/** Username or handle of the contributor. */
|
||||
contributed_by: string;
|
||||
/** Whether a human editor has verified this tempo map against the recording. */
|
||||
verified: boolean;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ─── Root document ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CTPDocument {
|
||||
/** Protocol version. Currently "1.0". */
|
||||
version: "1.0";
|
||||
metadata: CTPMetadata;
|
||||
count_in: CountIn;
|
||||
/**
|
||||
* Ordered list of sections. Must contain at least one entry.
|
||||
* Sections must be ordered by start_bar ascending, with no gaps.
|
||||
* The first section must have start_bar === 1.
|
||||
*/
|
||||
sections: CTPSection[];
|
||||
}
|
||||
|
||||
// ─── Derived helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns true if a section uses constant tempo (step transition). */
|
||||
export function isStepSection(s: CTPSection): s is StepSection {
|
||||
return s.transition === "step";
|
||||
}
|
||||
|
||||
/** Returns true if a section ramps tempo. */
|
||||
export function isRampSection(s: CTPSection): s is RampSection {
|
||||
return s.transition === "ramp";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the starting BPM of a section regardless of transition type.
|
||||
* For step sections this is simply `bpm`. For ramp sections it is `bpm_start`.
|
||||
*/
|
||||
export function sectionStartBpm(s: CTPSection): number {
|
||||
return isStepSection(s) ? s.bpm : s.bpm_start;
|
||||
}
|
||||
115
lib/ctp/validate.ts
Normal file
115
lib/ctp/validate.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
import type { CTPDocument } from "./schema";
|
||||
|
||||
// ─── Sub-schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TimeSignatureSchema = z.object({
|
||||
numerator: z.number().int().min(1).max(32),
|
||||
denominator: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((n) => [1, 2, 4, 8, 16, 32].includes(n), {
|
||||
message: "denominator must be a power of 2 (1, 2, 4, 8, 16, or 32)",
|
||||
}),
|
||||
});
|
||||
|
||||
const StepSectionSchema = z.object({
|
||||
label: z.string().min(1).max(64),
|
||||
start_bar: z.number().int().min(1),
|
||||
bpm: z.number().min(20).max(400),
|
||||
time_signature: TimeSignatureSchema,
|
||||
transition: z.literal("step"),
|
||||
});
|
||||
|
||||
const RampSectionSchema = z.object({
|
||||
label: z.string().min(1).max(64),
|
||||
start_bar: z.number().int().min(1),
|
||||
bpm_start: z.number().min(20).max(400),
|
||||
bpm_end: z.number().min(20).max(400),
|
||||
time_signature: TimeSignatureSchema,
|
||||
transition: z.literal("ramp"),
|
||||
});
|
||||
|
||||
const CTPSectionSchema = z.discriminatedUnion("transition", [
|
||||
StepSectionSchema,
|
||||
RampSectionSchema,
|
||||
]);
|
||||
|
||||
const CountInSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
bars: z.number().int().min(1).max(8),
|
||||
use_first_section_tempo: z.boolean(),
|
||||
});
|
||||
|
||||
const CTPMetadataSchema = z.object({
|
||||
title: z.string().min(1).max(256),
|
||||
artist: z.string().min(1).max(256),
|
||||
mbid: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.default(null),
|
||||
duration_seconds: z.number().positive(),
|
||||
contributed_by: z.string().min(1).max(64),
|
||||
verified: z.boolean(),
|
||||
created_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
// ─── Root schema ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CTPDocumentSchema = z
|
||||
.object({
|
||||
version: z.literal("1.0"),
|
||||
metadata: CTPMetadataSchema,
|
||||
count_in: CountInSchema,
|
||||
sections: z.array(CTPSectionSchema).min(1),
|
||||
})
|
||||
.superRefine((doc, ctx) => {
|
||||
const sections = doc.sections;
|
||||
|
||||
// First section must start at bar 1
|
||||
if (sections[0].start_bar !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sections", 0, "start_bar"],
|
||||
message: "The first section must start at bar 1",
|
||||
});
|
||||
}
|
||||
|
||||
// Sections must be sorted by start_bar (strictly ascending)
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
if (sections[i].start_bar <= sections[i - 1].start_bar) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sections", i, "start_bar"],
|
||||
message: `Section start_bar must be strictly greater than the previous section's start_bar (got ${sections[i].start_bar} after ${sections[i - 1].start_bar})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Exported validator ───────────────────────────────────────────────────────
|
||||
|
||||
export type CTPValidationResult =
|
||||
| { success: true; data: CTPDocument }
|
||||
| { success: false; errors: z.ZodError };
|
||||
|
||||
/**
|
||||
* Validates an unknown value as a CTPDocument.
|
||||
* Returns a typed result union instead of throwing.
|
||||
*/
|
||||
export function validateCTP(input: unknown): CTPValidationResult {
|
||||
const result = CTPDocumentSchema.safeParse(input);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data as CTPDocument };
|
||||
}
|
||||
return { success: false, errors: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and throws a descriptive error if the document is invalid.
|
||||
* Useful in scripts/CLI contexts.
|
||||
*/
|
||||
export function parseCTP(input: unknown): CTPDocument {
|
||||
return CTPDocumentSchema.parse(input) as CTPDocument;
|
||||
}
|
||||
Reference in New Issue
Block a user