Files
ai-agent/reel/music/render-music.mjs
Gabriel Vidal 945976fd2e feat(reel): an original score composed in code
music/ renders public/audio/score.wav (24-bit 48 kHz, exactly 120 s) with
plain TypeScript DSP in Node (`npm run music`): D major on the 王道進行
"royal road" progression (G△9 – A9 – F#m7 – Bm7), a two-bar hook in the yo
scale over the three drops, sidechained supersaws, a pluck arpeggio, FM
bells on the notification pings and glass chimes for the cron jobs. Every
drum hit is scheduled from src/score.ts, so the kicks sit within 0.2 ms of
the grid the picture pulses on. check-music.mjs re-measures loudness
(−15 LUFS, −1.3 dBTP), per-chapter levels and kick alignment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:50:47 +02:00

61 lines
2.4 KiB
JavaScript

/**
* Renders the score to public/audio/score.wav (48 kHz, stereo, 24-bit,
* exactly 5 760 000 frames = 120 s; bar 1 beat 1 is sample 0).
*
* music/song.ts and everything it imports are plain TypeScript DSP: esbuild
* bundles them into one ES module, which is imported here and run in Node.
* The render is deterministic: two runs give the same bytes.
*
* npm run music → public/audio/score.wav
* node music/render-music.mjs --raw --out mix.wav → the unmastered mix, 32-bit float
* node music/render-music.mjs --solo bass,pad --raw --out bass.wav → some buses alone
*
* Buses: drums fx bells pad stab night ring arp bass lead counter.
*/
import { build } from "esbuild";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const args = process.argv.slice(2);
const option = (name) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : undefined;
};
const raw = args.includes("--raw");
const solo = option("solo")?.split(",");
const out = resolve(option("out") ?? join(root, "public/audio/score.wav"));
const started = performance.now();
const bundle = await build({
entryPoints: [join(root, "music/song.ts")],
bundle: true,
format: "esm",
platform: "node",
target: "node20",
write: false,
logLevel: "warning",
});
const song = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
const { wav } = song;
const result = song.renderScore({ raw, solo });
const bytes = wav(result.L, result.R, raw);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, bytes);
const f = (x) => x.toFixed(2);
const timing = Object.entries(result.seconds)
.map(([k, v]) => `${k} ${v.toFixed(1)} s`)
.join(" · ");
console.log(`[score] ${result.L.length} frames @ 48000 Hz → ${out} (${(bytes.length / 1e6).toFixed(1)} MB, ${raw ? "32-bit float, unmastered" : "24-bit"})`);
console.log(`[score] ${timing}`);
const m = result.master;
if (m) {
console.log(
`[score] ${f(m.lufs)} LUFS · true peak ${f(m.truePeak)} dBTP · sample peak ${f(m.samplePeak)} dBFS · gain ${f(m.gainDb)} dB · limiter ≤ ${f(m.maxLimitDb)} dB · glue ≤ ${f(m.maxGlueDb)} dB`,
);
}
console.log(`[score] done in ${((performance.now() - started) / 1000).toFixed(1)} s`);