#!/usr/bin/env node // sfx-compose.mjs — build a track whose EVERY sound is generated by the // ElevenLabs SFX endpoint (/api/sfx). No synths, no samples, no vocals: // just text-prompted sound effects layered on a timeline and glued with // a psychedelic ffmpeg FX chain. // // Counterpart to big-pictures/cli.mjs (which builds jeffrey-vocal // tracks). Here the "score" is an arrangement file of sound prompts. // Each cue is content-hash cached by bin/sfx.mjs, so reruns are free. // // Arrangement file format (`.sfx`): // @title phosphene # ID3 title (default: slug) // @glue swirl # psychedelic glue: swirl | dub | none // @duration 88 # hard trim (seconds); omit = auto // // # cue lines — head is key=val tokens, prompt follows the colon: // # at=SEC gain=G dur=S [loop] fadein=S fadeout=S pan=-1..1 : prompt // at=0 gain=0.5 dur=26 loop fadein=5 fadeout=8 : deep warm analog drone, evolving // at=12 gain=0.6 dur=8 fadein=2 fadeout=4 pan=-0.5 : reverse cymbal swell rising // // Usage: // node bin/sfx-compose.mjs big-pictures/phosphene.sfx // node bin/sfx-compose.mjs phosphene # resolves big-pictures/.sfx // node bin/sfx-compose.mjs phosphene --force # bust sfx + mix caches // node bin/sfx-compose.mjs phosphene --no-finalize # stop at the raw mix import { spawnSync } from "node:child_process"; import { readFileSync, copyFileSync, existsSync, mkdirSync, statSync } from "node:fs"; import { resolve, dirname, basename } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; const HERE = dirname(fileURLToPath(import.meta.url)); const POP = resolve(HERE, ".."); const OUT = `${POP}/big-pictures/out`; 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 k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags[k] = next; i++; } else flags[k] = true; } else positional.push(a); } if (!positional[0]) { console.error("usage: node bin/sfx-compose.mjs [--force] [--no-finalize]"); process.exit(1); } // Resolve the arrangement file: explicit path, or big-pictures/.sfx. let ARR = resolve(process.cwd(), positional[0]); if (!existsSync(ARR)) { const alt = `${POP}/big-pictures/${positional[0].replace(/\.sfx$/, "")}.sfx`; if (existsSync(alt)) ARR = alt; else { console.error(`✗ arrangement not found: ${positional[0]}`); process.exit(1); } } const SLUG = basename(ARR).replace(/\.sfx$/, ""); const FORCE = flags.force === true; const FINALIZE = flags["no-finalize"] !== true; // ── parse arrangement ───────────────────────────────────────────────── const meta = { title: SLUG, glue: "swirl", duration: null }; const cues = []; for (const raw of readFileSync(ARR, "utf8").split("\n")) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; if (line.startsWith("@")) { const sp = line.indexOf(" "); const key = (sp === -1 ? line.slice(1) : line.slice(1, sp)).trim(); const val = sp === -1 ? "" : line.slice(sp + 1).trim(); if (key === "duration") meta.duration = Number(val) || null; else meta[key] = val; continue; } const ci = line.indexOf(":"); if (ci === -1) continue; const prompt = line.slice(ci + 1).trim(); if (!prompt) continue; const cue = { at: 0, gain: 0.7, dur: null, loop: false, fadein: 0, fadeout: 0, pan: 0, prompt }; for (const tok of line.slice(0, ci).trim().split(/\s+/)) { if (!tok) continue; const [k, v] = tok.split("="); if (k === "at") cue.at = Number(v) || 0; else if (k === "gain") cue.gain = Number(v); else if (k === "dur") cue.dur = Math.max(0.5, Math.min(30, Number(v))); else if (k === "loop") cue.loop = true; else if (k === "fadein") cue.fadein = Number(v) || 0; else if (k === "fadeout") cue.fadeout = Number(v) || 0; else if (k === "pan") cue.pan = Math.max(-1, Math.min(1, Number(v) || 0)); } cues.push(cue); } if (!cues.length) { console.error(`✗ no cues in ${ARR}`); process.exit(1); } const TITLE = flags.title || meta.title; const MIX = `${OUT}/${SLUG}-sfxmix.mp3`; const FINAL = `${OUT}/${SLUG}-final.mp3`; const DESK = `${homedir()}/Desktop/${SLUG}.mp3`; const sfxDir = `${OUT}/sfx/${SLUG}`; mkdirSync(sfxDir, { recursive: true }); function run(cmd, args, cwd = POP) { const r = spawnSync(cmd, args, { cwd, stdio: ["ignore", "inherit", "inherit"] }); if (r.status !== 0) { console.error(`✗ ${cmd} ${args.join(" ")} failed (exit ${r.status})`); process.exit(1); } } function probeDur(p) { const r = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", p], { encoding: "utf8" }); return Number((r.stdout || "0").trim()) || 0; } console.log(`━━━ ${SLUG} ━━━ ${cues.length} sfx cues · glue=${meta.glue}${meta.duration ? ` · trim ${meta.duration}s` : ""}\n`); // ── 1 · render every cue through /api/sfx (cached) ─────────────────── console.log("▸ 1 · render sfx cues"); const stems = []; cues.forEach((c, i) => { const p = `${sfxDir}/${String(i).padStart(2, "0")}.mp3`; const args = ["bin/sfx.mjs", "--text", c.prompt, "--out", p]; if (c.dur != null) args.push("--duration", String(c.dur)); if (c.loop) args.push("--loop"); if (FORCE) args.push("--force"); run("node", args); stems.push(p); }); // ── 2 · lay cues on a timeline + glue ──────────────────────────────── console.log("\n▸ 2 · compose timeline + psychedelic glue"); const inputs = []; for (const p of stems) inputs.push("-i", p); const stemFilters = []; const labels = []; let trackEnd = 0; cues.forEach((c, i) => { const len = c.dur != null ? c.dur : probeDur(stems[i]); trackEnd = Math.max(trackEnd, c.at + len); const d = Math.max(0, Math.round(c.at * 1000)); const chain = [`adelay=${d}|${d}`, `volume=${c.gain}`]; if (c.fadein > 0) chain.push(`afade=t=in:st=${c.at}:d=${c.fadein}`); if (c.fadeout > 0) chain.push(`afade=t=out:st=${(c.at + len - c.fadeout).toFixed(3)}:d=${c.fadeout}`); // Stereo placement: pan a copy of the (mono/stereo) stem across the field. if (c.pan !== 0) { const l = (1 - Math.max(0, c.pan)).toFixed(3); const rr = (1 + Math.min(0, c.pan)).toFixed(3); chain.push(`pan=stereo|c0=${l}*c0|c1=${rr}*c1`); } stemFilters.push(`[${i}:a]${chain.join(",")}[s${i}]`); labels.push(`[s${i}]`); }); // Psychedelic glue presets applied to the full mix. const GLUE = { swirl: "aphaser=in_gain=0.5:out_gain=0.8:delay=3.0:decay=0.5:speed=0.4," + "chorus=0.6:0.9:55|65|75:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3," + "aecho=0.8:0.7:90|330:0.4|0.25", dub: "aecho=0.8:0.85:180|420|760:0.5|0.3|0.18,aphaser=speed=0.3", none: "anull", }[meta.glue] || "anull"; const dur = meta.duration || (trackEnd + 1); // mix → glue → gentle loudness normalize → trim → 44.1k stereo const post = `amix=inputs=${cues.length}:duration=longest:dropout_transition=0:normalize=0[mix];` + `[mix]${GLUE},loudnorm=I=-15:TP=-1.5:LRA=11,atrim=duration=${dur.toFixed(2)},aresample=44100`; const filter = `${stemFilters.join(";")};${labels.join("")}${post}`; run("ffmpeg", ["-y", "-loglevel", "error", ...inputs, "-filter_complex", filter, "-c:a", "libmp3lame", "-q:a", "2", MIX]); console.log(` ✓ ${MIX} (${(statSync(MIX).size / 1024 / 1024).toFixed(2)} MB · ~${dur.toFixed(0)}s)`); // ── 3 · finalize (ID3 + auto cover) ────────────────────────────────── let result = MIX; if (FINALIZE) { console.log("\n▸ 3 · finalize (ID3 + cover)"); run("node", ["bin/finalize.mjs", "--in", MIX.replace(POP + "/", ""), "--slug", SLUG, "--title", TITLE, "--out", FINAL.replace(POP + "/", ""), "--force"]); result = FINAL; } copyFileSync(result, DESK); console.log(`\n✓ ~/Desktop/${SLUG}.mp3 ${(statSync(DESK).size / 1024 / 1024).toFixed(2)} MB`);