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>
160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|