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>
72 lines
2.9 KiB
JavaScript
72 lines
2.9 KiB
JavaScript
/**
|
||
* Mix-balancing aid: renders the raw mix with every bus kept apart, then
|
||
* prints each bus's loudness (LUFS, pre-master) per section and its octave
|
||
* band levels in a drop, a groove and the night. Read it before moving a
|
||
* fader in song.ts: in a drop the hook (lead) should sit on top, the kick
|
||
* and bass just under it.
|
||
*
|
||
* node music/balance.mjs
|
||
*/
|
||
import { build } from "esbuild";
|
||
import { dirname, join, resolve } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||
const entry = `export * from "./song"; export { integratedLoudness } from "./master"; export { Biquad } from "./dsp"; export { at } from "../src/timeline";`;
|
||
const bundle = await build({ stdin: { contents: entry, resolveDir: join(root, "music"), loader: "ts" }, bundle: true, format: "esm", platform: "node", write: false });
|
||
const { renderScore, integratedLoudness, Biquad, at } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
|
||
|
||
const SR = 48000;
|
||
const r = renderScore({ raw: true, stems: true });
|
||
const stems = { ...r.stems, MIX: { L: r.L, R: r.R } };
|
||
const SPANS = [
|
||
["1–2", 1, 2],
|
||
["3–4", 3, 4],
|
||
["drop1", 5, 8],
|
||
["9–16", 9, 16],
|
||
["17–24", 17, 24],
|
||
["25–30", 25, 30],
|
||
["night", 31, 34],
|
||
["35–38", 35, 38],
|
||
["drop2", 39, 44],
|
||
["45–50", 45, 50],
|
||
["51–58", 51, 58],
|
||
["drop3", 59, 62],
|
||
["63–64", 63, 64],
|
||
];
|
||
const slice = (s, a, b) => [s.L.slice(Math.round(at(a) * SR), Math.round(at(b + 1) * SR)), s.R.slice(Math.round(at(a) * SR), Math.round(at(b + 1) * SR))];
|
||
const lufs = (s, a, b) => {
|
||
const v = integratedLoudness(...slice(s, a, b));
|
||
return Number.isFinite(v) ? v.toFixed(1) : "—";
|
||
};
|
||
|
||
console.log("LUFS per bus (raw, before the master)");
|
||
console.log("bus".padEnd(9) + SPANS.map(([n]) => n.padStart(8)).join(""));
|
||
for (const [name, s] of Object.entries(stems)) console.log(name.padEnd(9) + SPANS.map(([, a, b]) => lufs(s, a, b).padStart(8)).join(""));
|
||
|
||
const BANDS = [31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||
for (const [label, a, b] of [
|
||
["drop 2 (39–44)", 39, 44],
|
||
["groove (9–16)", 9, 16],
|
||
["night (31–34)", 31, 34],
|
||
]) {
|
||
console.log(`\noctave bands, dB — ${label}`);
|
||
console.log("bus".padEnd(9) + BANDS.map((f) => String(f).padStart(7)).join(""));
|
||
for (const [name, s] of Object.entries(stems)) {
|
||
const [L, R] = slice(s, a, b);
|
||
const levels = BANDS.map((f) => {
|
||
let e = 0;
|
||
for (const x of [L, R]) {
|
||
const b1 = Biquad.bp(f, 1.41);
|
||
const b2 = Biquad.bp(f, 1.41);
|
||
for (let i = 0; i < x.length; i++) {
|
||
const y = b2.process(b1.process(x[i]));
|
||
e += y * y;
|
||
}
|
||
}
|
||
return 10 * Math.log10(e / (2 * L.length) + 1e-20);
|
||
});
|
||
if (Math.max(...levels) > -90) console.log(name.padEnd(9) + levels.map((v) => v.toFixed(1).padStart(7)).join(""));
|
||
}
|
||
}
|