Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472#!/usr/bin/env node// waltz.mjs — render a slow waltz bed for the recap.//// Two voices supported:// --voice piano Salamander grand-piano sample bank// (`fedac/native/samples/piano/<midi>.raw`,// mono float32 @ 48kHz, ~3s per anchor —// the same bank notepat plays through fedac/native/audio.c)// --voice sinebells pure-sine bell synth — fundamental + slightly// inharmonic partials, sharp attack, exponential decay.// No samples needed; renders directly.//// The rhythmic / harmonic logic is adapted from// `.vscode/tests/test-generative-waltz.mjs` and `artery/test-trapwaltz.mjs`,// simplified to a single voice: bass note on beat 1, chord triad on beats// 2 and 3 ("oom-pah-pah"), with a melodic line on top.//// The audience config can carry a `waltz` block that seeds the generator// (so each cut gets a distinct tune from the same instrument). Defaults// can also be overridden on the CLI for one-off renders.//// Usage:// node bin/waltz.mjs # default audience// node bin/waltz.mjs jeffrey-24h-2026-05-01 # named audience// node bin/waltz.mjs jeffrey-24h-2026-05-01 \// --voice sinebells --bpm 64 --scale minor \// --bars 24 --density 0.7 --seed bells-test \// --out ~/Desktop/waltz-bells.mp3
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync,} from "node:fs";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";import { spawnSync } from "node:child_process";import { homedir } from "node:os";
const HERE = dirname(fileURLToPath(import.meta.url));const ROOT = resolve(HERE, "..");const REPO = resolve(ROOT, "..");
// ── parse args ─────────────────────────────────────────────────────────const argv = process.argv.slice(2);const flags = {};const positional = [];for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i++; } else { flags[key] = true; } } else { positional.push(a); }}const audienceName = positional[0] || "jeffrey-24h";
function expandHome(p) { if (!p || typeof p !== "string") return p; if (p === "~") return homedir(); if (p.startsWith("~/")) return resolve(homedir(), p.slice(2)); return p;}
// ── load audience config ──────────────────────────────────────────────const { audience } = await import(`${ROOT}/audience/${audienceName}.mjs`);const W = audience.waltz || {};
const VOICE = flags.voice || W.voice || "piano";const SEED_STR = flags.seed || W.seed || audience.name || audienceName;const BPM = Number(flags.bpm ?? W.bpm ?? 80);const SCALE_NAME = flags.scale || W.scale || "major";const PROGRESSION = parseProgression(flags.progression) || W.progression || [0, 5, 3, 4]; // I vi IV Vconst VOICE_GAIN = Number(flags.gain ?? W.voiceGain ?? 0.18);const DENSITY = Number(flags.density ?? W.density ?? 0.5); // 0..1, melody/passing densityconst ROOT_OFFSET = Number(flags.transpose ?? W.transpose ?? 0); // semitones (default C)const OUT_PATH = expandHome(flags.out) || `${ROOT}/out/waltz.mp3`;
// Bar count is normally fixed (`--bars` / audience.waltz.bars / 24), but if// a target duration is given we auto-size BARS so the bed plays once// through and ends with the narration (no looping in compose). This is// the desired behavior for the recap pipeline — the waltz is composed,// not stream-looped, so the cadence resolves musically with the content.const _beatSecPre = 60 / BPM;const _barSecPre = _beatSecPre * 3;let DURATION_SEC = null;if (flags.duration !== undefined) { DURATION_SEC = Number(flags.duration);} else if (W.duration !== undefined) { DURATION_SEC = Number(W.duration);} else { // Fall back to out/duration.txt (written by align.mjs) when present, so // a bare `node bin/waltz.mjs <audience>` after the rest of the pipeline // produces a content-length bed without an explicit flag. const durFile = `${ROOT}/out/duration.txt`; if (existsSync(durFile)) { const t = Number(readFileSync(durFile, "utf8").trim()); if (Number.isFinite(t) && t > 0) DURATION_SEC = t; }}const BARS = (() => { if (flags.bars !== undefined) return Number(flags.bars); if (W.bars !== undefined && DURATION_SEC === null) return Number(W.bars); if (DURATION_SEC !== null) return Math.max(1, Math.ceil(DURATION_SEC / _barSecPre)); return 24;})();
function parseProgression(s) { if (!s || s === true) return null; return s.split(",").map((x) => Number(x.trim()));}
const SAMPLE_RATE = 48_000;
// ── deterministic PRNG seeded by audience name ────────────────────────function hashString(s) { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0;}function makeRng(seedStr) { let s = hashString(seedStr) || 1; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s >>>= 0; s ^= s << 5; s >>>= 0; return (s >>> 0) / 0xffffffff; };}const rng = makeRng(SEED_STR);
// ── musical theory ─────────────────────────────────────────────────────const SCALES = { major: [0, 2, 4, 5, 7, 9, 11], minor: [0, 2, 3, 5, 7, 8, 10], dorian: [0, 2, 3, 5, 7, 9, 10], lydian: [0, 2, 4, 6, 7, 9, 11],};const SCALE = SCALES[SCALE_NAME] || SCALES.major;
// ── voice: piano (sample bank) ────────────────────────────────────────const PIANO_SAMPLE_DIR = resolve(REPO, "fedac/native/samples/piano");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];let pianoBank = null;
function loadPianoBank() { const bank = new Map(); for (const m of PIANO_ANCHORS) { const path = `${PIANO_SAMPLE_DIR}/${m}.raw`; if (!existsSync(path)) throw new Error(`waltz: missing piano anchor ${path}`); const buf = readFileSync(path); const f32 = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); bank.set(m, Float32Array.from(f32)); } console.log(`→ piano bank · ${bank.size} anchors loaded`); return bank;}
function pianoAnchorFor(midi) { let best = PIANO_ANCHORS[0]; for (const a of PIANO_ANCHORS) if (Math.abs(a - midi) < Math.abs(best - midi)) best = a; const ratio = Math.pow(2, (midi - best) / 12); return { sample: pianoBank.get(best), ratio };}
function mixEventPiano(ev, out) { const { sample, ratio } = pianoAnchorFor(ev.midi); const startIdx = Math.floor(ev.startSec * SAMPLE_RATE); const durSamples = Math.floor(ev.durSec * SAMPLE_RATE); const attack = Math.min(0.005 * SAMPLE_RATE, durSamples * 0.05); const release = Math.min(0.08 * SAMPLE_RATE, durSamples * 0.5); const lenOut = durSamples + Math.floor(release);
for (let i = 0; i < lenOut; i++) { const dst = startIdx + i; if (dst < 0 || dst >= out.length) continue; const srcF = i * ratio; const s0 = Math.floor(srcF); const s1 = s0 + 1; if (s1 >= sample.length) break; const frac = srcF - s0; const v = sample[s0] * (1 - frac) + sample[s1] * frac; let env = 1; if (i < attack) env = i / attack; else if (i > durSamples) env = Math.max(0, 1 - (i - durSamples) / release); out[dst] += v * env * ev.gain; }}
// ── voice: sinebells ──────────────────────────────────────────────────// A bell is sines + slightly inharmonic partials with exponential decay.// Each partial has its own decay constant — the high partials die first,// leaving the fundamental to ring out, which is the ear's "bell" cue.// Ratios pulled from the Risset / Chowning / FOF bell tradition; we let// the strike attack be a few ms so it has presence in the mix.// Softened bell — fundamental dominates, the inharmonic clang and high// shimmer sit way back so the bed reads as "soft chime" not "struck bell."// Attack also softened (~12ms) to remove the percussive transient.const BELL_PARTIALS = [ { ratio: 0.5, amp: 0.28, decayT60: 5.5 }, // sub / "hum" { ratio: 1.0, amp: 1.00, decayT60: 4.5 }, // fundamental — keep loud and long { ratio: 2.0, amp: 0.32, decayT60: 2.6 }, // octave (harmonic, gentle) { ratio: 2.4, amp: 0.10, decayT60: 1.2 }, // inharmonic clang — way back { ratio: 3.0, amp: 0.09, decayT60: 1.0 }, { ratio: 4.5, amp: 0.04, decayT60: 0.6 }, { ratio: 5.4, amp: 0.02, decayT60: 0.4 }, // shimmer — barely there];const ATTACK_SEC = 0.012; // softer than 0.004 — reads as "chime" not "strike"const BELL_RING_TAIL = 6.0; // a hair longer for the gentler decayconst BELL_GAIN = 0.42; // global voice gain (was 0.55)
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12);}
function mixEventSinebell(ev, out) { const startIdx = Math.floor(ev.startSec * SAMPLE_RATE); const ringSamples = Math.floor((ev.durSec + BELL_RING_TAIL) * SAMPLE_RATE); const attackS = ATTACK_SEC * SAMPLE_RATE; const fundFreq = midiToFreq(ev.midi); const twoPiOverSr = (2 * Math.PI) / SAMPLE_RATE;
// Precompute per-partial omega + decay-per-sample. T60 = time to drop 60dB // (~ amplitude * 1e-3). decayPerSample = exp(-ln(1000) / (T60 * sr)). const partials = BELL_PARTIALS.map((p) => ({ omega: twoPiOverSr * fundFreq * p.ratio, amp: p.amp, decay: Math.exp(-Math.log(1000) / (p.decayT60 * SAMPLE_RATE)), }));
for (let i = 0; i < ringSamples; i++) { const dst = startIdx + i; if (dst < 0 || dst >= out.length) continue; let s = 0; for (const p of partials) { // running envelope: amp at t=0, multiplied by decay each sample const env = p.amp * Math.pow(p.decay, i); if (env < 1e-5) continue; s += Math.sin(p.omega * i) * env; } // Soft cosine attack — gentler than a linear ramp at the start let att = 1; if (i < attackS) att = 0.5 - 0.5 * Math.cos((Math.PI * i) / attackS); out[dst] += s * att * ev.gain * BELL_GAIN; }}
// ── route to chosen voice ─────────────────────────────────────────────let mixEvent;if (VOICE === "piano") { pianoBank = loadPianoBank(); mixEvent = mixEventPiano;} else if (VOICE === "sinebells") { console.log("→ voice · sinebells (no samples; pure synth)"); mixEvent = mixEventSinebell;} else { console.error(`waltz: unknown voice '${VOICE}'. expected: piano | sinebells`); process.exit(1);}
// ── morph sections ─────────────────────────────────────────────────────// audience.waltz.morph (optional) is a list of sections the bed walks// through across the duration. Each section can override progression /// scale / density / transpose. Sections are weighted by `weight` (default// 1) and the total bars get distributed proportionally so the whole bed// resolves at the end of the narration. If `morph` is absent, behavior// is identical to the prior fixed-progression mode (one virtual section// using the top-level progression / scale / density / transpose).//// Example (in audience.waltz):// morph: [// { progression: [0, 5, 3, 4], scale: "major", density: 0.4 },// { progression: [3, 0, 4, 5], scale: "lydian", density: 0.55, transpose: 2 },// { progression: [5, 4, 0, 3], scale: "dorian", density: 0.65 },// { progression: [0, 3, 4, 0, 5, 0], scale: "major", density: 0.5 },// ]const MORPH_RAW = (Array.isArray(W.morph) && W.morph.length > 0) ? W.morph : null;const MORPH = MORPH_RAW ? MORPH_RAW.map((s) => ({ progression: Array.isArray(s.progression) && s.progression.length > 0 ? s.progression : PROGRESSION, scale: SCALES[s.scale] || SCALE, scaleName: s.scale || SCALE_NAME, density: s.density !== undefined ? Number(s.density) : DENSITY, transpose: s.transpose !== undefined ? Number(s.transpose) : ROOT_OFFSET, weight: s.weight !== undefined ? Math.max(0.001, Number(s.weight)) : 1, })) : [ { progression: PROGRESSION, scale: SCALE, scaleName: SCALE_NAME, density: DENSITY, transpose: ROOT_OFFSET, weight: 1, }, ];
// Distribute BARS across sections by weight. We use floor + fix-up on the// last section so the sum stays exactly BARS.const TOTAL_WEIGHT = MORPH.reduce((a, s) => a + s.weight, 0);const SECTION_BARS = (() => { const out = MORPH.map((s) => Math.max(1, Math.floor((s.weight / TOTAL_WEIGHT) * BARS))); const sum = out.reduce((a, b) => a + b, 0); if (sum !== BARS) out[out.length - 1] += (BARS - sum); return out;})();
function sectionForBar(barIdx) { let acc = 0; for (let i = 0; i < MORPH.length; i++) { acc += SECTION_BARS[i]; if (barIdx < acc) { // Bar offset within the section — used to walk the section's progression. const localBar = barIdx - (acc - SECTION_BARS[i]); return { section: MORPH[i], localBar, sectionIndex: i }; } } // Defensive: last section if we somehow run past the end. const last = MORPH[MORPH.length - 1]; return { section: last, localBar: barIdx, sectionIndex: MORPH.length - 1 };}
function midiInScale(scale, rootOffset, degree, octaveOffset = 0) { const len = scale.length; const idx = ((degree % len) + len) % len; const octShift = Math.floor(degree / len); return (60 + rootOffset) + 12 * (octaveOffset + octShift) + scale[idx];}function chordMidisIn(scale, rootOffset, rootDegree, octaveOffset = 0) { return [ midiInScale(scale, rootOffset, rootDegree, octaveOffset), midiInScale(scale, rootOffset, rootDegree + 2, octaveOffset), midiInScale(scale, rootOffset, rootDegree + 4, octaveOffset), ];}
// ── build event list ───────────────────────────────────────────────────const beatSec = 60 / BPM;const barSec = beatSec * 3;const totalSec = barSec * BARS;const events = []; // { startSec, midi, gain, durSec }
for (let bar = 0; bar < BARS; bar++) { const barStart = bar * barSec; const { section, localBar } = sectionForBar(bar); const prog = section.progression; const scl = section.scale; const transpose = section.transpose; const density = section.density;
const deg = prog[localBar % prog.length]; const triad = chordMidisIn(scl, transpose, deg, 0); const bass = midiInScale(scl, transpose, deg, -2);
// Beat 1 — bass events.push({ startSec: barStart, midi: bass, gain: 0.55, durSec: beatSec * 1.1 });
// Beat 2 — triad for (const m of triad) { events.push({ startSec: barStart + beatSec, midi: m, gain: 0.30, durSec: beatSec * 0.9 }); } // Beat 3 — triad again, slightly softer for (const m of triad) { events.push({ startSec: barStart + 2 * beatSec, midi: m, gain: 0.26, durSec: beatSec * 0.9 }); }
// Melody on beat 1, frequency tunable via density (0 = sparse, 1 = every bar) const melodyEveryBar = density >= 0.85; const wantMelody = melodyEveryBar || (bar % 2 === 0) || rng() < density * 0.5; if (wantMelody) { const melDeg = deg + (rng() < 0.5 ? 4 : 2); // 5th or 3rd of chord const melMidi = midiInScale(scl, transpose, melDeg, 1); events.push({ startSec: barStart + beatSec * 0.05, midi: melMidi, gain: 0.40, durSec: beatSec * 2.0 }); }
// Passing tone on beat 3 — chance scales with density if (rng() < 0.25 + density * 0.5) { const passDeg = deg + 1 + Math.floor(rng() * 3); const passMidi = midiInScale(scl, transpose, passDeg, 1); events.push({ startSec: barStart + 2 * beatSec + beatSec * 0.5, midi: passMidi, gain: 0.30, durSec: beatSec * 0.6 }); }
// Optional: extra melodic ornament at high density if (density > 0.65 && rng() < 0.5) { const ornDeg = deg + (rng() < 0.5 ? 3 : 5); const ornMidi = midiInScale(scl, transpose, ornDeg, 1); events.push({ startSec: barStart + beatSec * 1.5, midi: ornMidi, gain: 0.28, durSec: beatSec * 0.5 }); }}
if (MORPH_RAW) { const summary = MORPH.map((s, i) => `${SECTION_BARS[i]}b·${s.scaleName}·d${s.density.toFixed(2)}·t${s.transpose}`).join(" → "); console.log(`→ morph · ${MORPH.length} sections: ${summary}`);}console.log(`→ waltz · voice=${VOICE} · ${BARS} bars · ${BPM} bpm · ${SCALE_NAME} · ${(totalSec).toFixed(1)}s · ${events.length} notes · seed=${SEED_STR}`);
// Export the deterministic event list so other tools (waltz-overlay.mjs,// future "music compositional sweep") can read it without re-deriving.{ const eventsPath = `${ROOT}/out/waltz-events.json`; const dir = OUT_PATH.replace(/\/[^/]+$/, ""); mkdirSync(dir, { recursive: true }); writeFileSync(eventsPath, JSON.stringify({ voice: VOICE, bpm: BPM, scale: SCALE_NAME, bars: BARS, beatSec, barSec, totalSec, seed: SEED_STR, events, }, null, 2)); console.log(`→ events · ${eventsPath} (${events.length} notes)`);}
// ── render ─────────────────────────────────────────────────────────────// Sinebells need extra tail for the ring-out; piano's natural sample tail// is already truncated by the per-event release.const tailSec = VOICE === "sinebells" ? BELL_RING_TAIL : 1.0;const totalSamples = Math.ceil((totalSec + tailSec) * SAMPLE_RATE);const out = new Float32Array(totalSamples);
for (const ev of events) mixEvent(ev, out);
// Normalize to ~ -3 dBFS peak, then scale to voiceGain.let peak = 0;for (let i = 0; i < out.length; i++) { const a = Math.abs(out[i]); if (a > peak) peak = a;}if (peak > 0) { const target = 0.7; const norm = target / peak; const finalGain = norm * VOICE_GAIN / 0.18; for (let i = 0; i < out.length; i++) out[i] *= finalGain;}
// ── write ──────────────────────────────────────────────────────────────const outDir = dirname(OUT_PATH);mkdirSync(outDir, { recursive: true });const rawPath = `${outDir}/.${audienceName}-${VOICE}.f32.raw`;
const buf = Buffer.alloc(out.length * 4);for (let i = 0; i < out.length; i++) buf.writeFloatLE(out[i], i * 4);writeFileSync(rawPath, buf);console.log(`→ wrote ${rawPath} (${(buf.length / 1024 / 1024).toFixed(2)} MB f32 mono ${SAMPLE_RATE}Hz)`);
const ff = spawnSync( "ffmpeg", [ "-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SAMPLE_RATE), "-ac", "1", "-i", rawPath, "-c:a", "libmp3lame", "-q:a", "3", OUT_PATH, ], { stdio: "inherit" });if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1);}try { unlinkSync(rawPath); } catch {}console.log(`✓ ${OUT_PATH}`);