Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
8.7 kB · 184 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185#!/usr/bin/env node// make-hum-loop.mjs — turn the IMG_8802 field recording (a CAR HORN,// dominant pitch ~C#) into a GRANULAR HARMONIC LOOP that opens the// amaythingra track under the boot chime.//// Concept (@jeffrey 2026-06-02): "a car horn i want in the intro… a more// granular smooth loop cuz it sort of harmonizes." A car horn is already// a sustained harmonic tone; granulating it (many small Hann-windowed// grains at jittered read positions) extends + smooths it into an// evolving loop that never audibly repeats, while keeping its bright// horn character.//// The horn sits on C# — a tritone against the track's G major pentatonic.// Pitched UP +1 semitone, C# → D (the drone's fifth) and the secondary// D# → E, so both partials land inside G pentatonic and the horn locks to// the bed's pads instead of fighting them. The lowpass just rounds off// the very top so it sits as a warm horn-drone, not a blaring honk.//// Granular synthesis runs in-process (JS), NOT via an ffmpeg filtergraph:// a dense grain cloud is thousands of grains, far past what amix can take.//// Reads: pop/big-pictures/voice-takes/IMG_8802.m4a (override --src)// Writes: pop/big-pictures/out/intro-hum.wav (stereo 48k)// pop/big-pictures/out/intro-hum-preview.mp3//// Usage:// node pop/big-pictures/bin/make-hum-loop.mjs \// [--semitones 1] [--length 22] [--grain 0.09] [--hop 0.028] \// [--jitter 0.9] [--lowpass 1500] [--no-cache]
import { execSync } from "node:child_process";import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, resolve } from "node:path";import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));const REPO = resolve(HERE, "../../..");const POP = resolve(REPO, "pop");
const argv = process.argv.slice(2);function flag(name, def) { const i = argv.indexOf(`--${name}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : def;}const SRC = resolve(flag("src", resolve(POP, "big-pictures/voice-takes/IMG_8802.m4a")));const OUT = resolve(flag("out", resolve(POP, "big-pictures/out/intro-hum.wav")));const OUT_MP3 = OUT.replace(/\.wav$/, "-preview.mp3");const SEMITONES = parseFloat(flag("semitones", "1")); // C# → Dconst LENGTH = parseFloat(flag("length", "58")); // total hum seconds (rides ~26s→1:24, into the saws)const GRAIN = parseFloat(flag("grain", "0.13")); // grain length (s)const HOP = parseFloat(flag("hop", "0.04")); // time between grain onsets (s)const JITTER = parseFloat(flag("jitter", "0.9")); // read-position jitter (fraction of usable core)const LOWPASS = parseFloat(flag("lowpass", "3800")); // a touch brighter so it meshes with the sawsconst BUMP = parseFloat(flag("bump", "0.06")); // kick-synced UPWARD pitch-bump depth (0 = off)const BUMP_BPM = parseFloat(flag("bump-bpm", "120")); // beat rate the bump syncs to (matches the bed)const BUMP_TAU = parseFloat(flag("bump-tau", "0.07")); // bump decay (s) — sharp spike on the beatconst CORE_A = parseFloat(flag("core-start", "1.0"));// steady region of the takeconst CORE_B = parseFloat(flag("core-end", "9.5"));const NO_CACHE = argv.includes("--no-cache");const SR = 48000;const TMP = "/tmp/intro-hum";mkdirSync(TMP, { recursive: true });mkdirSync(dirname(OUT), { recursive: true });
function sh(cmd) { execSync(cmd, { stdio: ["ignore", "ignore", "pipe"] }); }function dur(p) { return parseFloat(execSync( `ffprobe -v error -show_entries format=duration -of csv=p=0 "${p}"` ).toString().trim());}// deterministic PRNG (no Math.random — reruns stay identical)function makeRng(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };}
if (!existsSync(SRC)) { console.error(`✗ source not found: ${SRC}`); process.exit(1); }
// 1 — mono 48k + pitch up +SEMITONES (C# → D) → isolate a steady core.const CORE_F32 = `${TMP}/core.f32`;if (NO_CACHE || !existsSync(CORE_F32)) { const mono = `${TMP}/mono.wav`; sh(`ffmpeg -y -loglevel error -i "${SRC}" -ar ${SR} -ac 1 "${mono}"`); const pitched = `${TMP}/pitched.wav`; if (Math.abs(SEMITONES) > 0.001) { sh(`rubberband --pitch ${SEMITONES} --formant --crisp 5 "${mono}" "${pitched}" 2>/dev/null`); } else { execSync(`cp "${mono}" "${pitched}"`); } const srcDur = dur(pitched); const a = Math.max(0, Math.min(CORE_A, srcDur - 1)); const b = Math.min(CORE_B, srcDur); const coreLen = Math.max(1.5, b - a); // emit the core as raw float32 so JS can scatter grains sample-accurately sh(`ffmpeg -y -loglevel error -ss ${a.toFixed(3)} -t ${coreLen.toFixed(3)} -i "${pitched}" ` + `-af "loudnorm=I=-20:TP=-3:LRA=5" -ar ${SR} -ac 1 -f f32le "${CORE_F32}"`);}const coreBytes = readFileSync(CORE_F32);const core = new Float32Array(coreBytes.buffer, coreBytes.byteOffset, Math.floor(coreBytes.length / 4));const coreLen = core.length;const grainN = Math.max(8, Math.round(GRAIN * SR));const usable = Math.max(1, coreLen - grainN);
// 2 — GRANULAR CLOUD. Two independent grain streams (L / R) with separate// RNG seeds → naturally decorrelated stereo width, and the density// dissolves any voice. Each grain: Hann window, jittered read pos.const N = Math.round(LENGTH * SR);const L = new Float32Array(N);const R = new Float32Array(N);const hopN = Math.max(1, Math.round(HOP * SR));
function scatter(out, seed) { const rng = makeRng(seed); let grains = 0; for (let onset = 0; onset < N; onset += hopN) { // read position wanders across the whole usable core (heavy jitter) const read = Math.floor(rng() * usable * JITTER + (1 - JITTER) * 0.5 * usable); const r0 = Math.max(0, Math.min(usable - 1, read)); for (let i = 0; i < grainN; i++) { const o = onset + i; if (o >= N) break; // Hann window const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (grainN - 1)); out[o] += core[r0 + i] * w; } grains++; } return grains;}const gL = scatter(L, 0x1234567);const gR = scatter(R, 0x89abcdef);console.log(`granular cloud: ${gL}+${gR} grains · grain ${(GRAIN * 1000) | 0}ms · hop ${(HOP * 1000) | 0}ms · ${LENGTH}s`);
// kick-synced UPWARD pitch bump (@jeffrey "its sidechain a little pitch// bump, pitch up"): resample with rate r(t) = 1 + depth·exp(-(t mod beat)/τ)// so the pitch SPIKES UP on every beat (= the kick grid, since the hum is// delayed onto a beat at bake time) then settles. The hum is a texture, so// the tiny cumulative read-drift is inaudible.function pitchBump(buf) { if (BUMP <= 0) return buf; const beat = 60 / BUMP_BPM; const out = new Float32Array(buf.length); let p = 0; for (let j = 0; j < out.length; j++) { const phase = (j / SR) % beat; const rate = 1 + BUMP * Math.exp(-phase / BUMP_TAU); const i0 = Math.floor(p); if (i0 >= buf.length - 1) { out[j] = buf[buf.length - 1] || 0; continue; } out[j] = buf[i0] + (buf[i0 + 1] - buf[i0]) * (p - i0); p += rate; } return out;}const Lb = pitchBump(L), Rb = pitchBump(R);if (BUMP > 0) console.log(` pitch-bump: +${(BUMP * 100) | 0}% on each beat @ ${BUMP_BPM} BPM (kick-synced)`);
// normalize the raw cloud to ~ -3 dBFS before ffmpeg postlet peak = 0;for (let i = 0; i < N; i++) { const a = Math.abs(Lb[i]); if (a > peak) peak = a; const b = Math.abs(Rb[i]); if (b > peak) peak = b; }const norm = peak > 0 ? 0.7 / peak : 1;const inter = Buffer.alloc(N * 2 * 4);for (let i = 0; i < N; i++) { inter.writeFloatLE(Math.max(-1, Math.min(1, Lb[i] * norm)), i * 8); inter.writeFloatLE(Math.max(-1, Math.min(1, Rb[i] * norm)), i * 8 + 4);}const CLOUD = `${TMP}/cloud.f32`;writeFileSync(CLOUD, inter);
// 3 — gentle warm lowpass (round the top), long fades (swell in /// dissolve out), final loudnorm.// Long CRESCENDO — starts very quiet and grows over almost the whole loop// (@jeffrey "move it to the start, rly quiet, get louder as we progress"),// with just a short tail at the very end.const fadeIn = (LENGTH * 0.82).toFixed(2);const fadeOutStart = (LENGTH - 4).toFixed(2);execSync( `ffmpeg -y -loglevel error -f f32le -ar ${SR} -ac 2 -i "${CLOUD}" ` + `-af "lowpass=f=${LOWPASS},` + `afade=t=in:st=0:d=${fadeIn}:curve=exp,` + `afade=t=out:st=${fadeOutStart}:d=4,` + `loudnorm=I=-18:TP=-2:LRA=6" ` + `-ar ${SR} -ac 2 -c:a pcm_s16le "${OUT}"`);sh(`ffmpeg -y -loglevel error -i "${OUT}" -b:a 256k "${OUT_MP3}"`);console.log(`✓ ${OUT.replace(POP + "/", "pop/")} (${dur(OUT).toFixed(1)}s) — car horn pitched +${SEMITONES}st, granular`);console.log(`✓ ${OUT_MP3.replace(POP + "/", "pop/")} ← quick listen`);