Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

recap: prototype live piano overlay (waltz-events.json + waltz-keys.ass)

waltz.mjs now exports the deterministic event list to
out/waltz-events.json alongside out/waltz.mp3 — same data the synth
mixer used to generate the audio, available for downstream tools.

New bin/waltz-overlay.mjs reads that event list and emits an .ass
(libass) overlay file with timed Dialogue lines that draw filled
rectangles on a two-octave (C3–B4) notepat-style keyboard. Octave-
coded hues distinguish the lower vs upper register.

Composed locally as ~/Desktop/waltz-piano-overlay.mp4 (95s, ~2.4MB)
to verify the visual/timing read. Slot into the recap pipeline next
by adding waltz-keys.ass as a second subtitles= filter in compose.

See feedback memory: feedback_recap_waltz_piano_overlay.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+520
+147
recap/bin/waltz-overlay.mjs
··· 1 + #!/usr/bin/env node 2 + // waltz-overlay.mjs — render a live notepat-style piano-roll overlay for 3 + // the per-cut waltz, sourced from the deterministic event list that 4 + // `waltz.mjs` writes to `out/waltz-events.json`. 5 + // 6 + // Output: `out/waltz-keys.ass` — an Advanced SubStation Alpha file that 7 + // libass can render as a single ffmpeg `subtitles=` filter on top of the 8 + // composed video. Each note becomes a short Dialogue line drawing a 9 + // filled rectangle on its key, alive for the note's duration. 10 + // 11 + // Layout (1080×1920 portrait): 12 + // - Top of frame, 1080 × 140 strip, anchored at y=24 13 + // - Two-octave window: MIDI C3 (48) through B4 (71) by default 14 + // - Notes outside the window are clipped at the edges (rare in this 15 + // waltz — bass goes to ~A1, melody to ~C6 — clip to the window for 16 + // a clean two-octave notepat readout) 17 + // 18 + // Key drawing: 19 + // - White keys: 14 of them across 1080 → ~77 px wide 20 + // - Black keys: laid over the boundaries, ~46 px wide × 80 px tall 21 + // - Highlights: chapter-color (cycling per octave-group) filled 22 + // rectangle on the key, slightly inset, full key height 23 + // 24 + // Usage: node bin/waltz-overlay.mjs 25 + 26 + import { readFileSync, writeFileSync } from "node:fs"; 27 + import { resolve, dirname } from "node:path"; 28 + import { fileURLToPath } from "node:url"; 29 + 30 + const HERE = dirname(fileURLToPath(import.meta.url)); 31 + const ROOT = resolve(HERE, ".."); 32 + const eventsPath = `${ROOT}/out/waltz-events.json`; 33 + const assPath = `${ROOT}/out/waltz-keys.ass`; 34 + 35 + const { events, totalSec, bpm } = JSON.parse(readFileSync(eventsPath, "utf8")); 36 + 37 + // ── layout ───────────────────────────────────────────────────────────── 38 + const W = 1080; 39 + const KB_TOP = 24; 40 + const KB_H = 140; 41 + const MIDI_LOW = 48; // C3 42 + const MIDI_HIGH = 71; // B4 inclusive 43 + const N_WHITE = 14; // 7 white keys × 2 octaves 44 + const WHITE_W = W / N_WHITE; 45 + const BLACK_W = WHITE_W * 0.62; 46 + const BLACK_H = KB_H * 0.62; 47 + 48 + // White-key index for a midi pitch within an octave (C=0, D=1, E=2, F=3, G=4, A=5, B=6) 49 + const WHITE_OF = { 0: 0, 2: 1, 4: 2, 5: 3, 7: 4, 9: 5, 11: 6 }; 50 + const isBlack = (midi) => [1, 3, 6, 8, 10].includes(midi % 12); 51 + 52 + // X position (left edge) of a key on the keyboard 53 + function keyX(midi) { 54 + const semis = midi - MIDI_LOW; 55 + const octave = Math.floor(semis / 12); 56 + const inOct = semis % 12; 57 + if (!isBlack(midi)) { 58 + return (octave * 7 + WHITE_OF[inOct]) * WHITE_W; 59 + } 60 + // Black-key positioning: centered on the gap between two whites 61 + const leftWhite = ({ 1: 0, 3: 1, 6: 3, 8: 4, 10: 5 })[inOct]; 62 + return (octave * 7 + leftWhite + 1) * WHITE_W - BLACK_W / 2; 63 + } 64 + 65 + // ── ASS time format ──────────────────────────────────────────────────── 66 + function assTime(t) { 67 + const h = Math.floor(t / 3600); 68 + const m = Math.floor((t % 3600) / 60); 69 + const s = (t % 60).toFixed(2); 70 + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(5, "0")}`; 71 + } 72 + 73 + // ASS color = &HAABBGGRR (alpha + BGR). For opaque colors use AA=00. 74 + // Octave-coded hues so the eye groups bass vs melody. 75 + const HUE_BY_OCTAVE = { 76 + 3: "&H00C5F7FC", // cream → octave 3 (lower) 77 + 4: "&H00B469FF", // magenta → octave 4 (upper) 78 + 5: "&H00FFB4B4", // sky → fallbacks 79 + 2: "&H0070F0E0", // cyan → bass that lands here 80 + }; 81 + function colorFor(midi) { 82 + const oct = Math.floor(midi / 12) - 1; // C3 = MIDI 48 / 12 - 1 = 3 83 + return HUE_BY_OCTAVE[oct] || "&H00FFFFFF"; 84 + } 85 + 86 + // ASS drawing: a closed rectangle at given (x,y,w,h) using {\p1}m..l..{\p0}. 87 + function drawRect(w, h) { 88 + return `m 0 0 l ${w} 0 ${w} ${h} 0 ${h}`; 89 + } 90 + 91 + // ── header ───────────────────────────────────────────────────────────── 92 + const lines = [ 93 + "[Script Info]", 94 + "ScriptType: v4.00+", 95 + "PlayResX: 1080", 96 + "PlayResY: 1920", 97 + "WrapStyle: 0", 98 + "ScaledBorderAndShadow: yes", 99 + "", 100 + "[V4+ Styles]", 101 + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", 102 + "Style: White,Sans,1,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1", 103 + "Style: Black,Sans,1,&H00000000,&H00000000,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1", 104 + "Style: Hl,Sans,1,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1", 105 + "", 106 + "[Events]", 107 + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", 108 + ]; 109 + 110 + const tEnd = (totalSec || (events[events.length - 1]?.startSec + events[events.length - 1]?.durSec) || 60) + 1; 111 + const allEnd = assTime(tEnd); 112 + 113 + // ── Layer 0: static white-key keyboard background ────────────────────── 114 + for (let m = MIDI_LOW; m <= MIDI_HIGH; m++) { 115 + if (isBlack(m)) continue; 116 + const x = keyX(m); 117 + // White keys: cream-on-faint-glow with a thin separator on the right 118 + lines.push( 119 + `Dialogue: 0,0:00:00.00,${allEnd},White,,0,0,0,,{\\an7\\pos(${x.toFixed(1)},${KB_TOP})\\bord1\\3c&H00282838&\\1c&H00F5F0E8&\\p1}${drawRect(WHITE_W - 1, KB_H)}{\\p0}` 120 + ); 121 + } 122 + 123 + // ── Layer 1: static black-key keyboard background (drawn on top) ────── 124 + for (let m = MIDI_LOW; m <= MIDI_HIGH; m++) { 125 + if (!isBlack(m)) continue; 126 + const x = keyX(m); 127 + lines.push( 128 + `Dialogue: 1,0:00:00.00,${allEnd},Black,,0,0,0,,{\\an7\\pos(${x.toFixed(1)},${KB_TOP})\\bord0\\1c&H00100810&\\p1}${drawRect(BLACK_W, BLACK_H)}{\\p0}` 129 + ); 130 + } 131 + 132 + // ── Layer 2: per-event highlight overlays ────────────────────────────── 133 + for (const ev of events) { 134 + if (ev.midi < MIDI_LOW || ev.midi > MIDI_HIGH) continue; 135 + const x = keyX(ev.midi); 136 + const w = isBlack(ev.midi) ? BLACK_W : WHITE_W - 1; 137 + const h = isBlack(ev.midi) ? BLACK_H : KB_H; 138 + const start = assTime(ev.startSec); 139 + const end = assTime(Math.min(ev.startSec + ev.durSec, tEnd)); 140 + lines.push( 141 + `Dialogue: 2,${start},${end},Hl,,0,0,0,,{\\an7\\pos(${x.toFixed(1)},${KB_TOP})\\bord0\\1c${colorFor(ev.midi)}\\p1}${drawRect(w, h)}{\\p0}` 142 + ); 143 + } 144 + 145 + writeFileSync(assPath, lines.join("\n") + "\n"); 146 + const eventsInRange = events.filter((e) => e.midi >= MIDI_LOW && e.midi <= MIDI_HIGH).length; 147 + console.log(`✓ ${assPath} · ${eventsInRange}/${events.length} notes in range · ${totalSec.toFixed(1)}s · ${bpm} bpm`);
+373
recap/bin/waltz.mjs
··· 1 + #!/usr/bin/env node 2 + // waltz.mjs — render a slow waltz bed for the recap. 3 + // 4 + // Two voices supported: 5 + // --voice piano Salamander grand-piano sample bank 6 + // (`fedac/native/samples/piano/<midi>.raw`, 7 + // mono float32 @ 48kHz, ~3s per anchor — 8 + // the same bank notepat plays through fedac/native/audio.c) 9 + // --voice sinebells pure-sine bell synth — fundamental + slightly 10 + // inharmonic partials, sharp attack, exponential decay. 11 + // No samples needed; renders directly. 12 + // 13 + // The rhythmic / harmonic logic is adapted from 14 + // `.vscode/tests/test-generative-waltz.mjs` and `artery/test-trapwaltz.mjs`, 15 + // simplified to a single voice: bass note on beat 1, chord triad on beats 16 + // 2 and 3 ("oom-pah-pah"), with a melodic line on top. 17 + // 18 + // The audience config can carry a `waltz` block that seeds the generator 19 + // (so each cut gets a distinct tune from the same instrument). Defaults 20 + // can also be overridden on the CLI for one-off renders. 21 + // 22 + // Usage: 23 + // node bin/waltz.mjs # default audience 24 + // node bin/waltz.mjs jeffrey-24h-2026-05-01 # named audience 25 + // node bin/waltz.mjs jeffrey-24h-2026-05-01 \ 26 + // --voice sinebells --bpm 64 --scale minor \ 27 + // --bars 24 --density 0.7 --seed bells-test \ 28 + // --out ~/Desktop/waltz-bells.mp3 29 + 30 + import { 31 + readFileSync, 32 + writeFileSync, 33 + existsSync, 34 + mkdirSync, 35 + unlinkSync, 36 + } from "node:fs"; 37 + import { resolve, dirname } from "node:path"; 38 + import { fileURLToPath } from "node:url"; 39 + import { spawnSync } from "node:child_process"; 40 + import { homedir } from "node:os"; 41 + 42 + const HERE = dirname(fileURLToPath(import.meta.url)); 43 + const ROOT = resolve(HERE, ".."); 44 + const REPO = resolve(ROOT, ".."); 45 + 46 + // ── parse args ───────────────────────────────────────────────────────── 47 + const argv = process.argv.slice(2); 48 + const flags = {}; 49 + const positional = []; 50 + for (let i = 0; i < argv.length; i++) { 51 + const a = argv[i]; 52 + if (a.startsWith("--")) { 53 + const key = a.slice(2); 54 + const next = argv[i + 1]; 55 + if (next !== undefined && !next.startsWith("--")) { 56 + flags[key] = next; 57 + i++; 58 + } else { 59 + flags[key] = true; 60 + } 61 + } else { 62 + positional.push(a); 63 + } 64 + } 65 + const audienceName = positional[0] || "jeffrey-24h"; 66 + 67 + function expandHome(p) { 68 + if (!p || typeof p !== "string") return p; 69 + if (p === "~") return homedir(); 70 + if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); 71 + return p; 72 + } 73 + 74 + // ── load audience config ────────────────────────────────────────────── 75 + const { audience } = await import(`${ROOT}/audience/${audienceName}.mjs`); 76 + const W = audience.waltz || {}; 77 + 78 + const VOICE = flags.voice || W.voice || "piano"; 79 + const SEED_STR = flags.seed || W.seed || audience.name || audienceName; 80 + const BPM = Number(flags.bpm ?? W.bpm ?? 80); 81 + const SCALE_NAME = flags.scale || W.scale || "major"; 82 + const PROGRESSION = parseProgression(flags.progression) || W.progression || [0, 5, 3, 4]; // I vi IV V 83 + const BARS = Number(flags.bars ?? W.bars ?? 24); 84 + const VOICE_GAIN = Number(flags.gain ?? W.voiceGain ?? 0.18); 85 + const DENSITY = Number(flags.density ?? W.density ?? 0.5); // 0..1, melody/passing density 86 + const ROOT_OFFSET = Number(flags.transpose ?? W.transpose ?? 0); // semitones (default C) 87 + const OUT_PATH = expandHome(flags.out) || `${ROOT}/out/waltz.mp3`; 88 + 89 + function parseProgression(s) { 90 + if (!s || s === true) return null; 91 + return s.split(",").map((x) => Number(x.trim())); 92 + } 93 + 94 + const SAMPLE_RATE = 48_000; 95 + 96 + // ── deterministic PRNG seeded by audience name ──────────────────────── 97 + function hashString(s) { 98 + let h = 2166136261 >>> 0; 99 + for (let i = 0; i < s.length; i++) { 100 + h ^= s.charCodeAt(i); 101 + h = Math.imul(h, 16777619); 102 + } 103 + return h >>> 0; 104 + } 105 + function makeRng(seedStr) { 106 + let s = hashString(seedStr) || 1; 107 + return () => { 108 + s ^= s << 13; s >>>= 0; 109 + s ^= s >>> 17; s >>>= 0; 110 + s ^= s << 5; s >>>= 0; 111 + return (s >>> 0) / 0xffffffff; 112 + }; 113 + } 114 + const rng = makeRng(SEED_STR); 115 + 116 + // ── musical theory ───────────────────────────────────────────────────── 117 + const SCALES = { 118 + major: [0, 2, 4, 5, 7, 9, 11], 119 + minor: [0, 2, 3, 5, 7, 8, 10], 120 + dorian: [0, 2, 3, 5, 7, 9, 10], 121 + lydian: [0, 2, 4, 6, 7, 9, 11], 122 + }; 123 + const SCALE = SCALES[SCALE_NAME] || SCALES.major; 124 + const ROOT_MIDI = 60 + ROOT_OFFSET; // C4 by default 125 + 126 + function scaleNoteMidi(degree, octaveOffset = 0) { 127 + const len = SCALE.length; 128 + const idx = ((degree % len) + len) % len; 129 + const octShift = Math.floor(degree / len); 130 + return ROOT_MIDI + 12 * (octaveOffset + octShift) + SCALE[idx]; 131 + } 132 + 133 + function chordMidis(rootDegree, octaveOffset = 0) { 134 + return [ 135 + scaleNoteMidi(rootDegree, octaveOffset), 136 + scaleNoteMidi(rootDegree + 2, octaveOffset), 137 + scaleNoteMidi(rootDegree + 4, octaveOffset), 138 + ]; 139 + } 140 + 141 + // ── voice: piano (sample bank) ──────────────────────────────────────── 142 + const PIANO_SAMPLE_DIR = resolve(REPO, "fedac/native/samples/piano"); 143 + const PIANO_ANCHORS = [21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60, 63, 66, 69, 72, 75, 78, 81, 84, 87, 90, 93, 96]; 144 + let pianoBank = null; 145 + 146 + function loadPianoBank() { 147 + const bank = new Map(); 148 + for (const m of PIANO_ANCHORS) { 149 + const path = `${PIANO_SAMPLE_DIR}/${m}.raw`; 150 + if (!existsSync(path)) throw new Error(`waltz: missing piano anchor ${path}`); 151 + const buf = readFileSync(path); 152 + const f32 = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); 153 + bank.set(m, Float32Array.from(f32)); 154 + } 155 + console.log(`→ piano bank · ${bank.size} anchors loaded`); 156 + return bank; 157 + } 158 + 159 + function pianoAnchorFor(midi) { 160 + let best = PIANO_ANCHORS[0]; 161 + for (const a of PIANO_ANCHORS) if (Math.abs(a - midi) < Math.abs(best - midi)) best = a; 162 + const ratio = Math.pow(2, (midi - best) / 12); 163 + return { sample: pianoBank.get(best), ratio }; 164 + } 165 + 166 + function mixEventPiano(ev, out) { 167 + const { sample, ratio } = pianoAnchorFor(ev.midi); 168 + const startIdx = Math.floor(ev.startSec * SAMPLE_RATE); 169 + const durSamples = Math.floor(ev.durSec * SAMPLE_RATE); 170 + const attack = Math.min(0.005 * SAMPLE_RATE, durSamples * 0.05); 171 + const release = Math.min(0.08 * SAMPLE_RATE, durSamples * 0.5); 172 + const lenOut = durSamples + Math.floor(release); 173 + 174 + for (let i = 0; i < lenOut; i++) { 175 + const dst = startIdx + i; 176 + if (dst < 0 || dst >= out.length) continue; 177 + const srcF = i * ratio; 178 + const s0 = Math.floor(srcF); 179 + const s1 = s0 + 1; 180 + if (s1 >= sample.length) break; 181 + const frac = srcF - s0; 182 + const v = sample[s0] * (1 - frac) + sample[s1] * frac; 183 + let env = 1; 184 + if (i < attack) env = i / attack; 185 + else if (i > durSamples) env = Math.max(0, 1 - (i - durSamples) / release); 186 + out[dst] += v * env * ev.gain; 187 + } 188 + } 189 + 190 + // ── voice: sinebells ────────────────────────────────────────────────── 191 + // A bell is sines + slightly inharmonic partials with exponential decay. 192 + // Each partial has its own decay constant — the high partials die first, 193 + // leaving the fundamental to ring out, which is the ear's "bell" cue. 194 + // Ratios pulled from the Risset / Chowning / FOF bell tradition; we let 195 + // the strike attack be a few ms so it has presence in the mix. 196 + // Softened bell — fundamental dominates, the inharmonic clang and high 197 + // shimmer sit way back so the bed reads as "soft chime" not "struck bell." 198 + // Attack also softened (~12ms) to remove the percussive transient. 199 + const BELL_PARTIALS = [ 200 + { ratio: 0.5, amp: 0.28, decayT60: 5.5 }, // sub / "hum" 201 + { ratio: 1.0, amp: 1.00, decayT60: 4.5 }, // fundamental — keep loud and long 202 + { ratio: 2.0, amp: 0.32, decayT60: 2.6 }, // octave (harmonic, gentle) 203 + { ratio: 2.4, amp: 0.10, decayT60: 1.2 }, // inharmonic clang — way back 204 + { ratio: 3.0, amp: 0.09, decayT60: 1.0 }, 205 + { ratio: 4.5, amp: 0.04, decayT60: 0.6 }, 206 + { ratio: 5.4, amp: 0.02, decayT60: 0.4 }, // shimmer — barely there 207 + ]; 208 + const ATTACK_SEC = 0.012; // softer than 0.004 — reads as "chime" not "strike" 209 + const BELL_RING_TAIL = 6.0; // a hair longer for the gentler decay 210 + const BELL_GAIN = 0.42; // global voice gain (was 0.55) 211 + 212 + function midiToFreq(midi) { 213 + return 440 * Math.pow(2, (midi - 69) / 12); 214 + } 215 + 216 + function mixEventSinebell(ev, out) { 217 + const startIdx = Math.floor(ev.startSec * SAMPLE_RATE); 218 + const ringSamples = Math.floor((ev.durSec + BELL_RING_TAIL) * SAMPLE_RATE); 219 + const attackS = ATTACK_SEC * SAMPLE_RATE; 220 + const fundFreq = midiToFreq(ev.midi); 221 + const twoPiOverSr = (2 * Math.PI) / SAMPLE_RATE; 222 + 223 + // Precompute per-partial omega + decay-per-sample. T60 = time to drop 60dB 224 + // (~ amplitude * 1e-3). decayPerSample = exp(-ln(1000) / (T60 * sr)). 225 + const partials = BELL_PARTIALS.map((p) => ({ 226 + omega: twoPiOverSr * fundFreq * p.ratio, 227 + amp: p.amp, 228 + decay: Math.exp(-Math.log(1000) / (p.decayT60 * SAMPLE_RATE)), 229 + })); 230 + 231 + for (let i = 0; i < ringSamples; i++) { 232 + const dst = startIdx + i; 233 + if (dst < 0 || dst >= out.length) continue; 234 + let s = 0; 235 + for (const p of partials) { 236 + // running envelope: amp at t=0, multiplied by decay each sample 237 + const env = p.amp * Math.pow(p.decay, i); 238 + if (env < 1e-5) continue; 239 + s += Math.sin(p.omega * i) * env; 240 + } 241 + // Soft cosine attack — gentler than a linear ramp at the start 242 + let att = 1; 243 + if (i < attackS) att = 0.5 - 0.5 * Math.cos((Math.PI * i) / attackS); 244 + out[dst] += s * att * ev.gain * BELL_GAIN; 245 + } 246 + } 247 + 248 + // ── route to chosen voice ───────────────────────────────────────────── 249 + let mixEvent; 250 + if (VOICE === "piano") { 251 + pianoBank = loadPianoBank(); 252 + mixEvent = mixEventPiano; 253 + } else if (VOICE === "sinebells") { 254 + console.log("→ voice · sinebells (no samples; pure synth)"); 255 + mixEvent = mixEventSinebell; 256 + } else { 257 + console.error(`waltz: unknown voice '${VOICE}'. expected: piano | sinebells`); 258 + process.exit(1); 259 + } 260 + 261 + // ── build event list ─────────────────────────────────────────────────── 262 + const beatSec = 60 / BPM; 263 + const barSec = beatSec * 3; 264 + const totalSec = barSec * BARS; 265 + const events = []; // { startSec, midi, gain, durSec } 266 + 267 + for (let bar = 0; bar < BARS; bar++) { 268 + const barStart = bar * barSec; 269 + const deg = PROGRESSION[bar % PROGRESSION.length]; 270 + const triad = chordMidis(deg, 0); 271 + const bass = scaleNoteMidi(deg, -2); 272 + 273 + // Beat 1 — bass 274 + events.push({ startSec: barStart, midi: bass, gain: 0.55, durSec: beatSec * 1.1 }); 275 + 276 + // Beat 2 — triad 277 + for (const m of triad) { 278 + events.push({ startSec: barStart + beatSec, midi: m, gain: 0.30, durSec: beatSec * 0.9 }); 279 + } 280 + // Beat 3 — triad again, slightly softer 281 + for (const m of triad) { 282 + events.push({ startSec: barStart + 2 * beatSec, midi: m, gain: 0.26, durSec: beatSec * 0.9 }); 283 + } 284 + 285 + // Melody on beat 1, frequency tunable via DENSITY (0 = sparse, 1 = every bar) 286 + const melodyEveryBar = DENSITY >= 0.85; 287 + const wantMelody = melodyEveryBar || (bar % 2 === 0) || rng() < DENSITY * 0.5; 288 + if (wantMelody) { 289 + const melDeg = deg + (rng() < 0.5 ? 4 : 2); // 5th or 3rd of chord 290 + const melMidi = scaleNoteMidi(melDeg, 1); 291 + events.push({ startSec: barStart + beatSec * 0.05, midi: melMidi, gain: 0.40, durSec: beatSec * 2.0 }); 292 + } 293 + 294 + // Passing tone on beat 3 — chance scales with DENSITY 295 + if (rng() < 0.25 + DENSITY * 0.5) { 296 + const passDeg = deg + 1 + Math.floor(rng() * 3); 297 + const passMidi = scaleNoteMidi(passDeg, 1); 298 + events.push({ startSec: barStart + 2 * beatSec + beatSec * 0.5, midi: passMidi, gain: 0.30, durSec: beatSec * 0.6 }); 299 + } 300 + 301 + // Optional: extra melodic ornament at high density 302 + if (DENSITY > 0.65 && rng() < 0.5) { 303 + const ornDeg = deg + (rng() < 0.5 ? 3 : 5); 304 + const ornMidi = scaleNoteMidi(ornDeg, 1); 305 + events.push({ startSec: barStart + beatSec * 1.5, midi: ornMidi, gain: 0.28, durSec: beatSec * 0.5 }); 306 + } 307 + } 308 + 309 + console.log(`→ waltz · voice=${VOICE} · ${BARS} bars · ${BPM} bpm · ${SCALE_NAME} · ${(totalSec).toFixed(1)}s · ${events.length} notes · seed=${SEED_STR}`); 310 + 311 + // Export the deterministic event list so other tools (waltz-overlay.mjs, 312 + // future "music compositional sweep") can read it without re-deriving. 313 + { 314 + const eventsPath = `${ROOT}/out/waltz-events.json`; 315 + const dir = OUT_PATH.replace(/\/[^/]+$/, ""); 316 + mkdirSync(dir, { recursive: true }); 317 + writeFileSync(eventsPath, JSON.stringify({ 318 + voice: VOICE, bpm: BPM, scale: SCALE_NAME, bars: BARS, 319 + beatSec, barSec, totalSec, seed: SEED_STR, 320 + events, 321 + }, null, 2)); 322 + console.log(`→ events · ${eventsPath} (${events.length} notes)`); 323 + } 324 + 325 + // ── render ───────────────────────────────────────────────────────────── 326 + // Sinebells need extra tail for the ring-out; piano's natural sample tail 327 + // is already truncated by the per-event release. 328 + const tailSec = VOICE === "sinebells" ? BELL_RING_TAIL : 1.0; 329 + const totalSamples = Math.ceil((totalSec + tailSec) * SAMPLE_RATE); 330 + const out = new Float32Array(totalSamples); 331 + 332 + for (const ev of events) mixEvent(ev, out); 333 + 334 + // Normalize to ~ -3 dBFS peak, then scale to voiceGain. 335 + let peak = 0; 336 + for (let i = 0; i < out.length; i++) { 337 + const a = Math.abs(out[i]); 338 + if (a > peak) peak = a; 339 + } 340 + if (peak > 0) { 341 + const target = 0.7; 342 + const norm = target / peak; 343 + const finalGain = norm * VOICE_GAIN / 0.18; 344 + for (let i = 0; i < out.length; i++) out[i] *= finalGain; 345 + } 346 + 347 + // ── write ────────────────────────────────────────────────────────────── 348 + const outDir = dirname(OUT_PATH); 349 + mkdirSync(outDir, { recursive: true }); 350 + const rawPath = `${outDir}/.${audienceName}-${VOICE}.f32.raw`; 351 + 352 + const buf = Buffer.alloc(out.length * 4); 353 + for (let i = 0; i < out.length; i++) buf.writeFloatLE(out[i], i * 4); 354 + writeFileSync(rawPath, buf); 355 + console.log(`→ wrote ${rawPath} (${(buf.length / 1024 / 1024).toFixed(2)} MB f32 mono ${SAMPLE_RATE}Hz)`); 356 + 357 + const ff = spawnSync( 358 + "ffmpeg", 359 + [ 360 + "-hide_banner", "-y", "-loglevel", "error", 361 + "-f", "f32le", "-ar", String(SAMPLE_RATE), "-ac", "1", 362 + "-i", rawPath, 363 + "-c:a", "libmp3lame", "-q:a", "3", 364 + OUT_PATH, 365 + ], 366 + { stdio: "inherit" } 367 + ); 368 + if (ff.status !== 0) { 369 + console.error("✗ ffmpeg failed"); 370 + process.exit(1); 371 + } 372 + try { unlinkSync(rawPath); } catch {} 373 + console.log(`✓ ${OUT_PATH}`);