Files
ai-agent/reel/music/song.ts
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

439 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Tokunia score: 120 s at 128 BPM in D major.
*
* Two halves meet here:
* - the grid-locked half (stems.ts): drums, crashes, impacts, risers,
* downlifters, pings and chimes, mixed sample-exactly from src/score.ts;
* - the played half (below): supersaw pads and stabs, a pluck arp, sub and
* mid bass ducked on every kick, the HOOK and its counter-line (synth.ts),
* through a plate, a hall and a dotted-8th ping-pong delay (mixer.ts).
* master.ts then brings the mix to −15 LUFS / −1.3 dBTP.
*
* Everything is plain TypeScript DSP on whole-film buffers, rendered by
* music/render-music.mjs in Node. It is deterministic (seeded noise, no
* Math.random), so two renders are bit-identical, and bar 1 beat 1 is
* sample 0.
*/
import { RISERS } from "../src/score";
import { at, BEAT, SIXTEENTH } from "../src/timeline";
import { SR, Stem, db, mtof, type Stereo } from "./dsp";
import { reverbIR } from "./fx";
import {
ARP_STEPS,
ARP_VEL,
BASS_RHYTHM,
COUNTER,
HOOK,
HOOK_ANSWER,
NIGHT_VOICING,
STAB_RHYTHM,
VOICING,
chordAt,
type ChordName,
type Note,
} from "./harmony";
import { master, type MasterReport } from "./master";
import { convolve, curve, duck, eq, fader, filter, gain, mixInto, pingPong, sendInto, type Point } from "./mixer";
import { gridStems, LENGTH } from "./stems";
import { Synth, glide, type Env, type VoiceSpec } from "./synth";
export { wav } from "./master";
// ─── helpers ──────────────────────────────────────────────────────────────
/** Seconds of 16th `pos` in `bar`. */
const t16 = (bar: number, pos: number) => at(bar) + pos * SIXTEENTH;
const hz = (notes: number[]) => notes.map(mtof);
const within = (bar: number, ranges: [number, number][]) => ranges.some(([a, b]) => bar >= a && bar <= b);
/** No note may start in bar 30's dropped fourth beat or in bar 38's silent last 8th. */
const muted = (bar: number, pos: number) => (bar === 30 && pos >= 12) || (bar === 38 && pos >= 14);
const OFF = -Infinity;
/** The drum stems' level in the mix (dB). */
const DRUMS = -3;
// ─── the instruments ──────────────────────────────────────────────────────
const saw = (count: number, spread: number, width: number, env: Env): VoiceSpec => ({ wave: "saw", unison: { count, spread, width }, env });
const LEAD_ENV: Env = { attack: 0.004, decay: 0.3, sustain: 0.55, release: 0.2 };
/** held hook notes start to sing after ~180 ms */
const LEAD_VIBRATO = { cents: 14, rate: 5.6, delay: 0.18 };
/** Every instrument and its level (dB) into its bus. Seeds keep the unison phases fixed between renders. */
function instruments() {
const make = (spec: VoiceSpec, volume: number, seed: number) => ({ synth: new Synth(spec, seed), volume });
return {
pad: make(saw(7, 42, 0.85, { attack: 0.16, decay: 0.6, sustain: 0.8, release: 0.7 }), -10, 1),
stab: make(saw(7, 44, 0.9, { attack: 0.002, decay: 0.24, sustain: 0.22, release: 0.16 }), -4, 2),
stabHigh: make(saw(5, 30, 1, { attack: 0.002, decay: 0.2, sustain: 0.18, release: 0.14 }), -11, 16),
night: make(saw(5, 26, 0.7, { attack: 1.3, decay: 1, sustain: 0.85, release: 2.4 }), -9, 3),
ring: make(saw(7, 48, 1, { attack: 0.003, decay: 2.6, sustain: 0, release: 0.6 }), -8, 4),
arp: make(
{
...saw(2, 12, 0.5, { attack: 0.0015, decay: 0.2, sustain: 0, release: 0.08 }),
filter: { base: 420, octaves: 4.2, env: { attack: 0.001, decay: 0.14, sustain: 0.08, release: 0.1 }, q: 1.2 },
},
1,
5,
),
sub: make({ wave: "sine", env: { attack: 0.006, decay: 0.05, sustain: 1, release: 0.08 } }, -6, 6),
drone: make({ wave: "sine", env: { attack: 1.4, decay: 0.1, sustain: 1, release: 1.8 } }, -22, 7),
lastSub: make({ wave: "sine", env: { attack: 0.004, decay: 2.8, sustain: 0, release: 0.4 } }, -8, 8),
mid: make(
{
...saw(2, 14, 0.15, { attack: 0.002, decay: 0.18, sustain: 0.5, release: 0.06 }),
filter: { base: 170, octaves: 3.4, env: { attack: 0.002, decay: 0.13, sustain: 0.25, release: 0.09 }, q: 2.2 },
},
-11,
9,
),
lead: make({ ...saw(3, 18, 0.35, LEAD_ENV), vibrato: LEAD_VIBRATO }, -3.5, 10),
leadPluck: make({ wave: "square", env: { attack: 0.001, decay: 0.11, sustain: 0, release: 0.06 } }, -12.5, 11),
leadBody: make({ wave: "triangle", env: LEAD_ENV, vibrato: LEAD_VIBRATO }, -10.5, 12),
leadOct: make({ ...saw(3, 24, 0.5, LEAD_ENV), vibrato: LEAD_VIBRATO }, -10.5, 13),
counter: make({ wave: "square", unison: { count: 2, spread: 10, width: 0.4 }, env: { attack: 0.03, decay: 0.5, sustain: 0.65, release: 0.35 }, vibrato: { cents: 10, rate: 5, delay: 0.25 } }, -7, 14),
counterOct: make({ wave: "triangle", env: { attack: 0.02, decay: 0.4, sustain: 0.6, release: 0.3 } }, -12, 15),
};
}
type Parts = { [K in keyof ReturnType<typeof instruments>]: Synth };
// ─── the arrangement ──────────────────────────────────────────────────────
/** Bars → chord spans: consecutive half-bars on the same chord merge into one. */
function spans(from: number, to: number): { t0: number; t1: number; chord: ChordName }[] {
const out: { t0: number; t1: number; chord: ChordName }[] = [];
for (let bar = from; bar <= to; bar++) {
for (const beat of [1, 3]) {
const chord = chordAt(bar, beat);
const t = at(bar, beat);
const last = out[out.length - 1];
if (last && last.chord === chord && Math.abs(last.t1 - t) < 1e-9) last.t1 = t + 2 * BEAT;
else out.push({ t0: t, t1: t + 2 * BEAT, chord });
}
}
return out;
}
/** The three drops, where the hook plays: HOOK, HOOK_ANSWER, HOOK… two bars each. */
const DROP_BARS: [number, number][] = [
[5, 8],
[39, 44],
[59, 62],
];
const GROOVE_BARS: [number, number][] = [
[9, 24],
[45, 58],
];
const PLAYING: [number, number][] = [
[1, 30],
[35, 62],
];
/** Play `notes` from `bar`'s downbeat; each bar's first note is accented. */
function melody(notes: Note[], bar: number, play: (t: number, dur: number, midi: number, v: number) => void) {
for (const [start, len, midi] of notes) play(at(bar) + start * SIXTEENTH, len * SIXTEENTH * 0.92, midi, start % 16 === 0 ? 0.95 : 0.82);
}
function arrange(p: Parts) {
// pads: every chord, overlapping into the next (the night has its own pad)
for (const [a, b] of PLAYING) for (const s of spans(a, b)) p.pad.chord(hz(VOICING[s.chord].chord), s.t0, s.t1 - s.t0 + 0.04, 0.75);
// stabs: syncopated in the drops, on the offbeats in the grooves
for (let bar = 5; bar <= 62; bar++) {
const drop = within(bar, DROP_BARS);
if (!drop && !within(bar, GROOVE_BARS)) continue;
for (const pos of drop ? STAB_RHYTHM.drop : STAB_RHYTHM.offbeat) {
const v = drop ? (pos === 0 ? 0.95 : pos === 6 || pos === 13 ? 0.88 : 0.8) : 0.62;
const chord = VOICING[chordAt(bar, Math.floor(pos / 4) + 1)].chord;
p.stab.chord(hz(chord), t16(bar, pos), (drop ? 1.7 : 1.3) * SIXTEENTH, v);
// the drops add the top three chord tones an octave up: the same stab, twice as wide and bright
if (drop) p.stabHigh.chord(hz(chord.slice(-3).map((m) => m + 12)), t16(bar, pos), 1.5 * SIXTEENTH, v);
}
}
// the build's stabs: 8ths in bar 37, 16ths in bar 38, swelling into the gap
for (let pos = 0; pos < 16; pos += 2) p.stab.chord(hz(VOICING.G.chord), t16(37, pos), 1.2 * SIXTEENTH, 0.5 + 0.15 * (pos / 14));
for (let pos = 0; pos < 14; pos++) p.stab.chord(hz(VOICING[chordAt(38, Math.floor(pos / 4) + 1)].chord), t16(38, pos), 0.9 * SIXTEENTH, 0.62 + 0.38 * (pos / 13));
// the night pad: Bm, then G△7, two bars each
for (const s of spans(31, 34)) p.night.chord(hz(NIGHT_VOICING[s.chord] ?? VOICING[s.chord].chord), s.t0, s.t1 - s.t0 + 0.3, 0.8);
// the last chord, struck on bar 63 and left to ring
p.ring.chord(hz(VOICING.D.chord), at(63), 2.6, 0.9);
// the arp: 16ths through every chord
for (let bar = 1; bar <= 62; bar++) {
if (!within(bar, PLAYING)) continue;
for (let pos = 0; pos < 16; pos++) {
if (muted(bar, pos)) continue;
const tones = VOICING[chordAt(bar, Math.floor(pos / 4) + 1)].arp;
p.arp.note(mtof(tones[ARP_STEPS[pos]]), t16(bar, pos), 0.9 * SIXTEENTH, ARP_VEL[pos]);
}
}
// sub: the chord root, held legato; a slow drone through the night; one last D under the final chord
for (const [a, b] of [
[5, 30],
[35, 62],
]) {
for (const s of spans(a, b)) {
const end = s.t1 > at(30, 4) && s.t0 < at(31) ? at(30, 4) : s.t1;
p.sub.note(mtof(VOICING[s.chord].root), s.t0, end - s.t0 + (end === s.t1 ? 0.02 : 0), 0.9);
}
}
for (const s of spans(31, 34)) p.drone.note(mtof(VOICING[s.chord].root + 12), s.t0, s.t1 - s.t0 + 0.02, 0.7);
p.lastSub.note(mtof(VOICING.D.root), at(63), 3, 0.9);
// mid bass
const bassRhythm = (bar: number) => {
if (within(bar, [[25, 30]])) return BASS_RHYTHM.bounce;
if (within(bar, [[37, 38]])) return BASS_RHYTHM.build;
if (within(bar, [
[9, 16],
[45, 50],
]))
return BASS_RHYTHM.offbeat;
if (within(bar, [
[5, 8],
[17, 24],
[39, 44],
[51, 62],
]))
return BASS_RHYTHM.gallop;
return null;
};
for (let bar = 5; bar <= 62; bar++) {
for (const [pos, len, oct] of bassRhythm(bar) ?? []) {
if (muted(bar, pos)) continue;
const root = VOICING[chordAt(bar, Math.floor(pos / 4) + 1)].root + 12;
p.mid.note(mtof(root + oct), t16(bar, pos), len * SIXTEENTH * 0.85, oct ? 0.75 : 0.9);
}
}
// the hook, in the three drops; the octave layer only in drop 3
for (const [a, b] of DROP_BARS) {
for (let bar = a, k = 0; bar < b; bar += 2, k++) {
melody(k % 2 ? HOOK_ANSWER : HOOK, bar, (t, d, m, v) => {
p.lead.note(mtof(m), t, d, v);
p.leadPluck.note(mtof(m), t, d, v);
p.leadBody.note(mtof(m - 12), t, d, v);
if (a === 59) p.leadOct.note(mtof(m + 12), t, d, v);
});
}
}
// the counter-line: subdued at the end of chapter 01, full in 02, back in 06 and the montage
const counterAt: [number, number, number][] = [
[13, 16, 0.5],
[17, 24, 0.85],
[45, 50, 0.7],
[51, 58, 0.9],
];
for (const [a, b, vel] of counterAt) {
for (let bar = a; bar <= b; bar += 4) {
const notes = COUNTER.filter(([start]) => start < (b - bar + 1) * 16);
melody(notes, bar, (t, d, m, v) => {
p.counter.note(mtof(m), t, d, v * vel);
if (a === 51) p.counterOct.note(mtof(m + 12), t, d, v * vel);
});
}
}
}
// ─── the mix ──────────────────────────────────────────────────────────────
export interface RenderOptions {
/** keep only these buses (and their reverb/delay): a balancing aid */
solo?: string[];
/** skip the master stage: the raw mix */
raw?: boolean;
/** also return each bus as it enters the mix (post-fader), and each return: balancing aid */
stems?: boolean;
}
export interface RenderResult {
L: Float32Array;
R: Float32Array;
master: MasterReport | null;
seconds: Record<string, number>;
stems?: Record<string, Stereo>;
}
/** Faders (dB) and filter cutoffs (Hz) by bar position. Bar 30's fourth beat and bar 38's last 8th drop out. */
const AUTOMATION: Record<string, Point[]> = {
padLevel: [[1, -4], [3, -2, "ramp"], [4.99, 0, "ramp"], [5, 0], [9, -3], [17, -1], [25, -5], [30.75, OFF], [35, -9], [38.85, -1, "ramp"], [38.875, OFF], [39, 1], [45, -3], [51, -1], [59, 1.5], [63, OFF]],
padCut: [[1, 650], [3, 1300, "ramp"], [4.99, 5500, "ramp"], [5, 6000], [9, 3600], [17, 4600], [25, 2100], [35, 800], [38.875, 7000, "ramp"], [39, 6500], [45, 3600], [51, 5000], [59, 7000]],
stabLevel: [[1, OFF], [5, 0], [9, -6], [17, -3], [25, OFF], [37, -7], [38.85, -2, "ramp"], [38.875, OFF], [39, 2], [45, -5], [51, -2], [59, 2.5], [63, OFF]],
stabCut: [[1, 3200], [5, 7500], [9, 3200], [17, 4500], [25, 3000], [37, 1400], [38.875, 8000, "ramp"], [39, 7500], [45, 3400], [51, 4600], [59, 8500]],
nightLevel: [[1, OFF], [31, -8], [35, -10], [36, OFF, "ramp"]],
nightCut: [[1, 600], [31, 600], [34.99, 1100, "ramp"]],
arpLevel: [[1, -9], [3, -6, "ramp"], [4.99, -3, "ramp"], [5, -9], [9, -4], [17, -3], [25, -9], [30.75, OFF], [35, -15], [38.85, -3, "ramp"], [38.875, OFF], [39, -8], [45, -4], [51, -3], [59, -7], [63, OFF]],
arpCut: [[1, 500], [3, 1100, "ramp"], [4.99, 7000, "ramp"], [5, 5200], [9, 4600], [17, 9000], [25, 2200], [35, 600], [38.875, 9000, "ramp"], [39, 5500], [45, 5000], [51, 9000], [59, 6000]],
bassLevel: [[1, 0], [30.75, OFF], [31, 0], [38.875, OFF], [39, 0]],
leadLevel: [[1, 0], [59, 1]],
leadCut: [[1, 6000], [39, 7000], [59, 8200]],
counterLevel: [[1, -7], [17, 0], [45, -3], [51, 0]],
};
export function renderScore(opts: RenderOptions = {}): RenderResult {
const seconds: Record<string, number> = {};
let clock = performance.now();
const lap = (name: string) => {
const now = performance.now();
seconds[name] = (now - clock) / 1000;
clock = now;
};
const N = LENGTH;
const audible = (bus: string) => !opts.solo || opts.solo.includes(bus);
const level = (name: string) => curve(AUTOMATION[name], N, fader);
const cutoff = (name: string) => curve(AUTOMATION[name], N);
const grid = gridStems();
lap("grid");
const mix = new Stem(N);
const sends = { plate: new Float32Array(N), hall: new Float32Array(N), delay: new Float32Array(N) };
type Sends = Partial<Record<keyof typeof sends, number>>;
const stems: Record<string, Stereo> = {};
const keep = (name: string, s: Stereo) => {
if (!opts.stems) return;
if (stems[name]) mixInto(stems[name], s);
else stems[name] = { L: s.L.slice(), R: s.R.slice() };
};
const out = (bus: string, s: Stereo, sendDb: Sends = {}, stem = bus) => {
if (!audible(bus)) return;
keep(stem, s);
mixInto(mix, s);
for (const [k, v] of Object.entries(sendDb) as [keyof typeof sends, number][]) sendInto(sends[k], s, db(v));
};
// ── grid stems, plus the synth risers (a saw stack climbing 2–3 octaves into each drop) ──
for (const st of [grid.kick, grid.hats, grid.snap, grid.crash]) gain(st, db(DRUMS));
out("drums", grid.kick, {}, "kick");
out("drums", grid.hats, {}, "hats");
out("drums", grid.snap, { plate: -15 }, "snap");
out("drums", grid.crash, { hall: -20 }, "crash");
const risers = new Stem(N);
for (const r of RISERS) {
const t0 = at(r.fromBar);
const n = Math.round((at(r.toBar) - t0) * SR);
const g = glide(n, mtof(45), mtof(45 + 12 * (r.toBar - r.fromBar > 2 ? 3 : 2)), 5, 40, 90 + r.fromBar);
for (let i = 0; i < n; i++) {
const amp = 10 ** ((-60 + 60 * (i / n) ** 0.6) / 20) * Math.min(1, (n - i) / (0.004 * SR));
g.L[i] *= amp;
g.R[i] *= amp;
}
risers.add(t0, g, db(-17));
}
filter(risers, "hp", 300, 0.7);
mixInto(grid.fx, risers);
out("fx", grid.fx, { hall: -14 });
out("bells", grid.bells, { delay: -9, plate: -13, hall: -12 });
lap("stems");
// ── the played layers ──
const inst = instruments();
const parts = Object.fromEntries(Object.entries(inst).map(([k, v]) => [k, v.synth])) as Parts;
arrange(parts);
const render = (...names: (keyof Parts)[]) => {
const s = new Stem(N);
for (const k of names) {
const part = new Stem(N);
inst[k].synth.render(part);
mixInto(s, part, db(inst[k].volume));
}
return s;
};
if (audible("pad")) {
const s = render("pad");
filter(s, "hp", 180);
eq(s, 320, 0.9, -3); // the mud: supersaws pile up at 300 Hz
filter(s, "lp", cutoff("padCut"), 0.5, 4);
duck(s, grid.duckLong, 0.72);
gain(s, level("padLevel"));
out("pad", s, { hall: -10 });
}
if (audible("stab")) {
const s = render("stab", "stabHigh");
filter(s, "hp", 210);
eq(s, 320, 0.9, -3);
filter(s, "lp", cutoff("stabCut"), 0.9, 4);
duck(s, grid.duckLong, 0.5);
gain(s, level("stabLevel"));
out("stab", s, { plate: -14, hall: -16 });
}
if (audible("night")) {
const s = render("night");
filter(s, "hp", 120);
filter(s, "lp", cutoff("nightCut"), 0.4);
gain(s, level("nightLevel"));
out("night", s, { hall: -6 });
}
if (audible("ring")) {
const s = render("ring");
filter(s, "hp", 150);
filter(s, "lp", 5200, 0.5);
out("ring", s, { hall: -4 });
}
if (audible("arp")) {
const s = render("arp");
filter(s, "hp", 260);
filter(s, "lp", cutoff("arpCut"), 0.6);
duck(s, grid.duckLong, 0.3);
gain(s, level("arpLevel"));
out("arp", s, { delay: -8, plate: -14 });
}
if (audible("bass")) {
const mid = render("mid");
filter(mid, "hp", 75);
filter(mid, "lp", 2400);
const s = render("sub", "drone", "lastSub");
mixInto(s, mid);
duck(s, grid.duckShort, 0.93);
gain(s, level("bassLevel"));
out("bass", s);
}
if (audible("lead")) {
const s = render("lead", "leadPluck", "leadBody", "leadOct");
filter(s, "hp", 250);
filter(s, "lp", cutoff("leadCut"), 0.8);
duck(s, grid.duckLong, 0.15);
gain(s, level("leadLevel"));
out("lead", s, { delay: -8, hall: -13 });
}
if (audible("counter")) {
const s = render("counter", "counterOct");
filter(s, "hp", 230);
filter(s, "lp", 2600, 0.5);
duck(s, grid.duckLong, 0.25);
gain(s, level("counterLevel"));
out("counter", s, { delay: -9, hall: -11 });
}
lap("synths");
// ── returns: the dotted-8th ping-pong (which also feeds the hall), the plate, the hall ──
const delay = pingPong(sends.delay, Math.round(3 * SIXTEENTH * SR), 0.3);
filter(delay, "hp", 380);
filter(delay, "lp", 6000);
gain(delay, db(-3));
keep("delay", delay);
mixInto(mix, delay);
sendInto(sends.hall, delay, db(-14 + 3));
const plate = convolve(sends.plate, reverbIR(1.6, 0.012, 2600, 801));
filter(plate, "hp", 320);
filter(plate, "lp", 9000);
gain(plate, db(-2));
keep("plate", plate);
mixInto(mix, plate);
const hall = convolve(sends.hall, reverbIR(3.0, 0.028, 1500, 811));
filter(hall, "hp", 240);
filter(hall, "lp", 7000);
gain(hall, db(-2));
keep("hall", hall);
mixInto(mix, hall);
lap("returns");
const report = opts.raw ? null : master(mix.L, mix.R, { lufs: -15, ceiling: -1.3, fadeOut: 0.4 });
lap("master");
return { L: mix.L, R: mix.R, master: report, seconds, stems: opts.stems ? stems : undefined };
}