/** * 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; }