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

184 lines
5.8 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 harmony and the tunes, as data.
*
* Key: D major. The loop is 王道進行, the J-pop "royal road" — IV△7 – V7 –
* iii7 – vi (G△7 – A7 – F#m7 – Bm7), one chord per bar — a nod to Tokunia's
* Japanese name. The night breakdown sits on Bm and G△7, the build climbs
* Em7 – F#m7 – G△7 – A9sus4 → A, and the film ends on a held D(add9).
*
* The HOOK is built from E-F#-A-B-C# — the 陽音階 (yo scale) on E — so it
* sits on every chord of the loop: the same two bars over G–A and F#m–Bm.
*/
export type ChordName = "G" | "A" | "F#m" | "Bm" | "Em" | "Asus" | "D";
/** MIDI notes per chord: the sub root, the chord voicing (pads + stabs), the arp's five tones. */
export const VOICING: Record<ChordName, { root: number; chord: number[]; arp: number[] }> = {
G: { root: 31, chord: [55, 59, 62, 66, 69], arp: [67, 71, 74, 78, 81] }, // G△9
A: { root: 33, chord: [57, 61, 64, 67, 71], arp: [69, 73, 76, 79, 83] }, // A9
"F#m": { root: 30, chord: [54, 57, 61, 64, 69], arp: [66, 69, 73, 76, 78] }, // F#m7
Bm: { root: 35, chord: [54, 59, 62, 66, 69], arp: [66, 71, 74, 78, 81] }, // Bm7
Em: { root: 28, chord: [52, 55, 59, 62, 66], arp: [64, 67, 71, 74, 78] }, // Em9
Asus: { root: 33, chord: [57, 62, 64, 67, 71], arp: [69, 74, 76, 79, 81] }, // A9sus4
D: { root: 38, chord: [50, 57, 62, 64, 66, 69], arp: [74, 76, 78, 81, 86] }, // D(add9)
};
/** The night's open, low voicings (bars 31–34). */
export const NIGHT_VOICING: Partial<Record<ChordName, number[]>> = {
Bm: [47, 54, 62, 69, 73], // Bm9: B2 F#3 D4 A4 C#5
G: [43, 50, 59, 66, 69], // G△9: G2 D3 B3 F#4 A4
};
const LOOP: ChordName[] = ["G", "A", "F#m", "Bm"];
/** Where the royal-road loop (re)starts on G. */
const LOOP_STARTS = [1, 5, 39, 45, 51, 59];
/** The chord sounding at `bar`, `beat` (1-indexed). */
export function chordAt(bar: number, beat = 1): ChordName {
if (bar >= 63) return "D";
if (bar >= 31 && bar <= 32) return "Bm";
if (bar >= 33 && bar <= 34) return "G";
if (bar === 35) return "Em";
if (bar === 36) return "F#m";
if (bar === 37) return "G";
if (bar === 38) return beat < 3 ? "Asus" : "A";
// the last bar of drop 3 leans on the dominant for the final cadence
if (bar === 62 && beat >= 3) return "Asus";
const start = Math.max(...LOOP_STARTS.filter((s) => s <= bar));
return LOOP[(bar - start) % 4];
}
/** A melody note: start and length in 16ths from the phrase's downbeat, MIDI pitch. */
export type Note = [start: number, len: number, midi: number];
const F5 = 78; // F#5
const A5 = 81;
const B5 = 83;
const E5 = 76;
const D5 = 74;
const CS5 = 73; // C#5
/** Bar 1 of the hook — the 3+3+2 / 3+3+2 call over IV△7 (or iii7). */
const HOOK_CALL: Note[] = [
[0, 3, F5],
[3, 3, A5],
[6, 2, F5],
[8, 3, E5],
[11, 3, F5],
[14, 2, A5],
];
/** THE HOOK — two bars, the film's theme: call, then the answer falling to E. */
export const HOOK: Note[] = [...HOOK_CALL, [16, 3, B5], [19, 3, A5], [22, 2, F5], [24, 6, E5]];
/** The hook's second statement in a 4-bar phrase: same call, the answer resolves to D. */
export const HOOK_ANSWER: Note[] = [
...HOOK_CALL,
[16, 3, B5],
[19, 3, A5],
[22, 2, F5],
[24, 2, E5],
[26, 2, F5],
[28, 4, D5],
];
/**
* The counter-line (4 bars over G–A–F#m–Bm): the hook in augmentation — its
* notes at half speed — so the chapters between the drops still hum the theme.
*/
export const COUNTER: Note[] = [
[0, 6, F5],
[6, 6, A5],
[12, 4, F5],
[16, 6, E5],
[22, 6, F5],
[28, 4, A5],
[32, 6, B5], // a 4–3 suspension over F#m7…
[38, 6, A5], // …resolving
[44, 4, F5],
[48, 8, D5],
[56, 4, CS5],
[60, 4, E5],
];
/** The arp: 16 steps into the chord's five arp tones — 3+3+3+3+4, syncopated against the kick. */
export const ARP_STEPS = [0, 2, 4, 1, 3, 4, 0, 2, 4, 1, 3, 4, 0, 2, 3, 4];
/** Accents on each group's first note. */
export const ARP_VEL = [1, 0.62, 0.7, 0.92, 0.62, 0.7, 0.95, 0.62, 0.7, 0.92, 0.62, 0.7, 0.95, 0.62, 0.7, 0.66];
/** Mid-bass rhythms: [16th position, length in 16ths, octave offset in semitones]. */
export const BASS_RHYTHM = {
/** classic house offbeats with an octave pickup */
offbeat: [
[2, 2, 0],
[6, 2, 0],
[10, 2, 0],
[14, 1, 0],
[15, 1, 12],
],
/** galloping offbeats — the drive chapters and the drops */
gallop: [
[2, 1, 0],
[3, 1, 0],
[6, 1, 0],
[7, 1, 12],
[10, 1, 0],
[11, 1, 0],
[14, 1, 0],
[15, 1, 12],
],
/** octave bounce under the notifications */
bounce: [
[2, 2, 0],
[6, 2, 12],
[10, 2, 0],
[14, 2, 12],
],
/** straight eighths for the last two bars of the build */
build: [
[0, 2, 0],
[2, 2, 0],
[4, 2, 0],
[6, 2, 0],
[8, 2, 0],
[10, 2, 0],
[12, 2, 0],
[14, 2, 12],
],
} satisfies Record<string, [number, number, number][]>;
/** Chord-stab rhythms (16th positions). */
export const STAB_RHYTHM = {
/** the drops: 3+3+4+3+3, locked to the hook's syncopation */
drop: [0, 3, 6, 10, 13],
/** the grooves: on the offbeats, between the kicks */
offbeat: [2, 6, 10, 14],
};
/**
* The notification bells, one per score PINGS entry (same order). A second
* pitch is a grace note a 16th later: the "ask" rises, the "answer" settles.
* The cascade climbs the scale to C#7, the dominant's third, into the dropout.
*/
export const PING_NOTES: (number | [number, number])[] = [
81, // 25.1 A5
86, // 25.3 D6
85, // 26.1 C#6
88, // 26.3 E6
[83, 88], // 27.1 the ask: B5 → E6
[90, 86], // 28.1 the answer: F#6 → D6
78, // 29.1 cascade: F#5
81, // 29.2 A5
83, // 29.3 B5
86, // 29.4 D6
88, // 30.1 E6
90, // 30.1+ F#6
93, // 30.2 A6
95, // 30.2+ B6
97, // 30.3 C#7
];
/** The cron chimes (CHIMES, same order): the hook's first four notes, slowed to a crawl. */
export const CHIME_NOTES = [90, 93, 90, 88]; // F#6 A6 F#6 E6