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

160 lines
6.1 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 grid-locked half of the score. Every drum hit (HITS), crash, drop
* impact, riser, downlifter, notification ping and cron chime from
* src/score.ts is mixed into a full-length stem at its exact sample, along
* with the kick's sidechain curves. song.ts mixes these stems as they are:
* nothing is re-scheduled, so the picture's `useHit()` pulses land on these
* very samples.
*/
import { CHIMES, CRASHES, DOWNLIFTERS, DROPS, HITS, PINGS, RISERS } from "../src/score";
import { at, BAR, BEAT, DURATION, SIXTEENTH } from "../src/timeline";
import { SR, Stem, db, toSample } from "./dsp";
import { clap, crash, hat, kick, snare, tick } from "./drums";
import { bell, chime, downlifter, impact, noiseRiser, reverseSwell } from "./fx";
import { CHIME_NOTES, PING_NOTES } from "./harmony";
/** Samples in the film: 120 s × 48 kHz = 5 760 000. */
export const LENGTH = toSample(DURATION);
/** The balance inside the stems (dB); song.ts's buses set the rest. */
const LEVEL = {
kick: 1.5,
hat: -7,
openhat: -9,
tick: -6,
clap: 1,
snare: -8,
crash: -7,
impact: -7,
riser: -12,
swell: -13,
downlifter: -15,
ping: -8,
chime: -14,
};
export interface GridStems {
kick: Stem;
/** hats, open hats, clock ticks */
hats: Stem;
/** clap and snare (they get the plate) */
snap: Stem;
crash: Stem;
/** impacts, risers, downlifters, reverse swells */
fx: Stem;
/** notification pings and cron chimes */
bells: Stem;
/** 0…1 kick-ducking curves: short for the bass, long for chords, pads and arp */
duckShort: Float32Array;
duckLong: Float32Array;
}
const barOf = (t: number) => Math.floor(t / BAR + 1e-9) + 1;
/** Snare pitch: the rolls into a drop climb. */
function snarePitch(t: number): number {
const bar = barOf(t);
if (bar === 4) return 1 + 0.25 * ((t - at(4, 3)) / (2 * BEAT));
if (bar === 37 || bar === 38) return 1 + 0.4 * ((t - at(37)) / (2 * BAR));
if (bar === 58) return 1 + 0.3 * ((t - at(58, 3)) / (2 * BEAT));
return 1;
}
export function gridStems(): GridStems {
const kicks = new Stem(LENGTH);
const hats = new Stem(LENGTH);
const snap = new Stem(LENGTH);
const crashes = new Stem(LENGTH);
const fx = new Stem(LENGTH);
const bells = new Stem(LENGTH);
// kick: one shot for the whole film, except the filtered four-on-the-floor of bars 3–4
const fullKick = kick();
for (const h of HITS.kick) {
const bar = barOf(h.t);
const shot = bar === 3 || bar === 4 ? kick(260 * (1800 / 260) ** ((h.t - at(3)) / (2 * BAR))) : fullKick;
kicks.add(h.t, shot, db(LEVEL.kick) * h.v);
}
HITS.hat.forEach((h, i) => hats.add(h.t, hat(1000 + i, false, h.v < 0.6), db(LEVEL.hat) * h.v ** 1.25, 0.15));
HITS.openhat.forEach((h, i) => hats.add(h.t, hat(5000 + i, true, false), db(LEVEL.openhat) * h.v, -0.2));
HITS.tick.forEach((h, i) => hats.add(h.t, tick(9000 + i, h.v < 0.6), db(LEVEL.tick) * h.v));
const claps = [clap(31), clap(32), clap(33)];
HITS.clap.forEach((h, i) => snap.add(h.t, claps[i % claps.length], db(LEVEL.clap) * h.v));
HITS.snare.forEach((h, i) => snap.add(h.t, snare(700 + i, snarePitch(h.t)), db(LEVEL.snare) * h.v));
// crashes: two alternating cymbals, a bigger one on the drops and the final hit
const regular = [crash(11, false), crash(12, false)];
const big = crash(21, true);
HITS.crash.forEach((h, i) => {
const bar = barOf(h.t);
crashes.add(h.t, DROPS.includes(bar) || bar === CRASHES[CRASHES.length - 1] ? big : regular[i % 2], db(LEVEL.crash) * h.v);
});
// the drops' sub impact, and a softer one under the last hit
const boom = impact(41);
for (const bar of DROPS) fx.add(at(bar), boom, db(LEVEL.impact));
fx.add(at(63), boom, db(LEVEL.impact - 5));
// risers end on their last sample, the sample before the downbeat
RISERS.forEach((r, i) => fx.add(at(r.fromBar), noiseRiser(at(r.toBar) - at(r.fromBar), 51 + i), db(LEVEL.riser)));
// downlifters: a reverse swell lands on the downbeat, then the noise falls away from it
DOWNLIFTERS.forEach((bar, i) => {
const last = bar === 63;
const swell = last ? 2 * BEAT : BEAT; // bar 30's empty fourth beat / bar 62's last two beats
fx.add(at(bar) - swell, reverseSwell(swell, 61 + i), db(LEVEL.swell));
fx.add(at(bar), downlifter(2 * BAR, 71 + i), db(LEVEL.downlifter - (last ? 5 : 0)));
});
// notification pings: the motif, the ask, the answer, then the cascade climbing left → right
PINGS.forEach((t, i) => {
const note = PING_NOTES[i];
const [first, grace] = Array.isArray(note) ? note : [note, null];
const cascade = i >= 6 ? (i - 6) / 8 : -1;
const v = cascade >= 0 ? 0.72 + 0.28 * cascade : grace !== null ? 0.95 : 0.85;
const pan = cascade >= 0 ? -0.45 + 0.9 * cascade : i % 2 ? 0.2 : -0.2;
bells.add(t, bell(first), db(LEVEL.ping) * v, pan);
if (grace !== null) bells.add(t + SIXTEENTH, bell(grace), db(LEVEL.ping) * 0.75, -pan);
});
CHIMES.forEach((t, i) => bells.add(t, chime(CHIME_NOTES[i]), db(LEVEL.chime) * (i === 3 ? 0.7 : 0.85), [-0.3, 0.3, -0.15, 0.25][i]));
// the last hit rings the notification bell once more: D6 + A6 over the final D chord
bells.add(at(63), bell(86), db(LEVEL.ping) * 0.55, -0.2);
bells.add(at(63), bell(93), db(LEVEL.ping) * 0.4, 0.2);
return {
kick: kicks,
hats,
snap,
crash: crashes,
fx,
bells,
duckShort: duck(0.03, 0.2),
duckLong: duck(0.008, 0.36),
};
}
/**
* The sidechain: for every kick, a duck that ramps in over the millisecond
* before the hit, holds for `hold`, then eases back to 0 by `release`
* ((1 − x)² — quick at first, landing softly). A ghost kick ducks a little less.
*/
function duck(hold: number, release: number, lead = 0.001): Float32Array {
const out = new Float32Array(LENGTH);
for (const h of HITS.kick) {
const depth = 0.6 + 0.4 * h.v;
const s0 = toSample(h.t - lead);
const total = toSample(lead + release);
for (let i = 0; i < total; i++) {
const t = i / SR - lead;
const d = t < 0 ? (t + lead) / lead : t < hold ? 1 : (1 - (t - hold) / (release - hold)) ** 2;
const j = s0 + i;
if (j >= 0 && j < LENGTH && d * depth > out[j]) out[j] = d * depth;
}
}
return out;
}