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:
AJ Avezzano
2026-04-01 11:14:46 -04:00
commit 5b772655c6
31 changed files with 2762 additions and 0 deletions

255
lib/ctp/render.ts Normal file
View 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;
}