From e0ba4f5b1512e699404bf37a6841208c7af684d6 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 3 Jul 2026 10:39:25 -0700 Subject: [PATCH] marketing/podcast: melody+rhythm bed, drum kits, beat-align, voice mix - jingle.mjs: renderBed() lo-fi underscore (pad+melody+drums), warm/low melody, 5 swappable drum kits (felt/brush/wood/808/pulse) - produce.mjs: per-sentence beat-alignment to the bed grid; voice chain (high-pass + presence/air EQ + compression, upfront) with sidechain-ducked bed + phaser sweep; /api/say retry; episode metadata sidecar - cover.mjs: attribution line tweak --- marketing/podcast/bin/jingle.mjs | 130 +++++++++++++++++++++- marketing/podcast/bin/produce.mjs | 172 +++++++++++++++++++++++------- 2 files changed, 261 insertions(+), 41 deletions(-) diff --git a/marketing/podcast/bin/jingle.mjs b/marketing/podcast/bin/jingle.mjs index 273b54aa03..5c0c7077c2 100644 --- a/marketing/podcast/bin/jingle.mjs +++ b/marketing/podcast/bin/jingle.mjs @@ -16,6 +16,10 @@ import { fileURLToPath } from "node:url"; const SR = 44100; +// Tempo of the bed — exported so produce.mjs can quantize speech onsets to +// the same grid (beat-align). +export const BED_BPM = 72; + // ── tiny WAV (PCM16 stereo) encoder ──────────────────────────────────── function encodeWav(left, right) { const n = left.length; @@ -45,9 +49,12 @@ function encodeWav(left, right) { // A struck bell at frequency f, hit at time `at` (s), ringing for `ring` (s). // Inharmonic partial stack + exponential decay + a touch of stereo spread. -function addBell(L, R, f, at, ring, gain, pan = 0) { +function addBell(L, R, f, at, ring, gain, pan = 0, bright = 1) { + // `bright` scales the upper (inharmonic) partials — 1 = full shimmer, + // lower = warmer/darker (used for the bed so it sits under the voice). const partials = [ - [1.0, 1.0], [2.01, 0.5], [2.99, 0.34], [4.18, 0.22], [5.43, 0.12], + [1.0, 1.0], [2.01, 0.5 * bright], [2.99, 0.34 * bright], + [4.18, 0.22 * bright], [5.43, 0.12 * bright], ]; const start = Math.floor(at * SR); const len = Math.floor(ring * SR); @@ -126,6 +133,125 @@ export function renderJingles(outDir) { return { intro, outro }; } +// ── the bed: a soft lo-fi underscore for under the reading ────────────── +// Slow chord pad + sparse pentatonic melody + a gentle kick/hat pulse. +// Deterministic (seeded LCG) so re-runs are byte-identical. +function lcg(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; } + +function addKick(L, R, at, gain, opt = {}) { + const { f0 = 105, f1 = 45, pdrop = 28, adec = 8.5, len = 0.3 } = opt; + const start = Math.floor(at * SR), n = Math.floor(len * SR); + for (let i = 0; i < n; i++) { + const t = i / SR; + const f = f1 + (f0 - f1) * Math.exp(-t * pdrop); + const env = Math.exp(-t * adec); + const s = Math.sin(2 * Math.PI * f * t) * env * gain; + const idx = start + i; + if (idx < L.length) { L[idx] += s; R[idx] += s; } + } +} +// Filtered-noise percussion (hat / shaker / brush). `hp` mixes in a crude +// high-pass (1 = bright tick, 0 = fuller brush); `dec` sets the decay rate. +function addNoise(L, R, at, gain, rnd, { dur = 0.05, dec = 90, hp = 1 } = {}) { + const start = Math.floor(at * SR), n = Math.floor(dur * SR); + let prev = 0; + for (let i = 0; i < n; i++) { + const t = i / SR; + const env = Math.exp(-t * dec); + const white = rnd() * 2 - 1; + const filt = hp * (white - prev) + (1 - hp) * white; prev = white; + const s = filt * env * gain; + const idx = start + i; + if (idx < L.length) { L[idx] += s * 0.85; R[idx] += s; } + } +} +// Short pitched click — woodblock / rim / tick. +function addTone(L, R, at, freq, gain, { dur = 0.09, dec = 45 } = {}) { + const start = Math.floor(at * SR), n = Math.floor(dur * SR); + for (let i = 0; i < n; i++) { + const t = i / SR; + const s = Math.sin(2 * Math.PI * freq * t) * Math.exp(-t * dec) * gain; + const idx = start + i; + if (idx < L.length) { L[idx] += s; R[idx] += s; } + } +} + +// ── drum kits — the swappable rhythm layer ───────────────────────────── +export const KITS = ["felt", "brush", "wood", "eight0eight", "pulse"]; + +function drums(L, R, dur, rnd, beat, kit) { + for (let t = 0, b = 0; t < dur; t += beat, b++) { + const ib = b % 4, half = beat / 2; + switch (kit) { + case "brush": // soft kick on 1, brushed backbeat on 2 & 4, quiet shaker + if (ib === 0) addKick(L, R, t, 0.42, { len: 0.34, adec: 7 }); + if (ib === 1 || ib === 3) addNoise(L, R, t, 0.12, rnd, { dur: 0.16, dec: 26, hp: 0.35 }); + addNoise(L, R, t + half, 0.04, rnd, { dur: 0.06, dec: 80 }); + break; + case "wood": // wooden kick + syncopated woodblock + if (ib === 0 || ib === 2) addKick(L, R, t, 0.4, { f0: 180, f1: 70, len: 0.16, adec: 16 }); + addTone(L, R, t + half, 820, 0.12, { dur: 0.06, dec: 70 }); + if (ib === 2) addTone(L, R, t + half * 1.5, 1180, 0.08, { dur: 0.05, dec: 80 }); + break; + case "eight0eight": // 808: long sub kick + tight 16th ticks + if (ib === 0) addKick(L, R, t, 0.6, { f0: 80, f1: 34, pdrop: 16, adec: 4.5, len: 0.55 }); + if (ib === 2) addKick(L, R, t + half, 0.5, { f0: 80, f1: 34, pdrop: 16, adec: 4.5, len: 0.55 }); + for (let q = 0; q < 4; q++) addNoise(L, R, t + q * (beat / 4), q % 2 ? 0.05 : 0.08, rnd, { dur: 0.03, dec: 120 }); + break; + case "pulse": // heartbeat — two soft kicks, no cymbals + if (ib === 0) { addKick(L, R, t, 0.5); addKick(L, R, t + beat * 0.55, 0.3); } + break; + case "felt": // default lo-fi: kick on 1 & 3, shaker on eighths + default: + if (ib === 0 || ib === 2) addKick(L, R, t, 0.5); + addNoise(L, R, t, 0.05, rnd, { dur: 0.05, dec: 90 }); + addNoise(L, R, t + half, 0.085, rnd, { dur: 0.05, dec: 90 }); + break; + } + } +} + +export function renderBed(durationSec, outPath, opts = {}) { + const kit = opts.kit || "felt"; + const dur = Math.max(4, durationSec); + const L = new Float32Array(Math.ceil(dur * SR)); + const R = new Float32Array(L.length); + const rnd = lcg(0x51ED9); + const bpm = BED_BPM, beat = 60 / bpm, bar = beat * 4; + + // Chord pad — I · vi · IV · V, two bars each, gently overlapping. + const prog = [ + [130.81, 164.81, 196.00], // C (C3 E3 G3) + [110.00, 130.81, 164.81], // Am (A2 C3 E3) + [ 87.31, 110.00, 130.81], // F (F2 A2 C3) + [ 98.00, 123.47, 146.83], // G (G2 B2 D3) + ]; + let ci = 0; + for (let t = 0; t < dur; t += bar * 2) { + const [r, th, fi] = prog[ci++ % prog.length]; + const d = Math.min(bar * 2 + 0.7, dur - t + 0.7); + addPad(L, R, r, t, d, 0.055); + addPad(L, R, th, t, d, 0.035); + addPad(L, R, fi, t, d, 0.042); + } + + // Rhythm — swappable drum kit (default "felt" = the current sound). + drums(L, R, dur, rnd, beat, kit); + + // Melody — sparse pentatonic music-box line, a random walk in C. Kept an + // octave down (C4–C5) and timbrally warm so it sits under the voice. + const penta = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25]; + let mi = 2; + for (let t = 0.5; t < dur; t += beat * (rnd() < 0.5 ? 2 : 3)) { + mi = Math.max(0, Math.min(penta.length - 1, mi + (rnd() < 0.5 ? -1 : 1) * (rnd() < 0.3 ? 2 : 1))); + addBell(L, R, penta[mi], t, 3.4, 0.24, rnd() * 0.6 - 0.3, 0.4); // bright=0.4 → warm + } + + normalize(L, R, 0.72); + writeFileSync(outPath, encodeWav(L, R)); + return outPath; +} + if (import.meta.url === `file://${process.argv[1]}`) { const HERE = dirname(fileURLToPath(import.meta.url)); const out = resolve(HERE, "..", "assets"); diff --git a/marketing/podcast/bin/produce.mjs b/marketing/podcast/bin/produce.mjs index 1e5262fd28..064f42a167 100644 --- a/marketing/podcast/bin/produce.mjs +++ b/marketing/podcast/bin/produce.mjs @@ -20,7 +20,7 @@ import { fileURLToPath } from "node:url"; import { createHash } from "node:crypto"; import { execFileSync, spawnSync } from "node:child_process"; import { essayToScript } from "./essay-to-script.mjs"; -import { renderJingles } from "./jingle.mjs"; +import { renderJingles, renderBed, BED_BPM } from "./jingle.mjs"; import { renderCover } from "./cover.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -73,25 +73,42 @@ async function say(text, label) { return out; } process.stdout.write(` → ${label} (${text.length} chars) …`); - const res = await fetch(SAY_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - redirect: "follow", - }); - if (!res.ok) { - process.stdout.write(" ✗\n"); - throw new Error(`/api/say ${res.status}: ${(await res.text()).slice(0, 200)}`); - } - const ct = res.headers.get("content-type") || ""; - if (!ct.includes("audio") && !ct.includes("octet-stream")) { - // Some deployments return JSON {audio: base64}; handle both. - const j = await res.json().catch(() => null); - if (j && j.audio) { writeFileSync(out, Buffer.from(j.audio, "base64")); } - else throw new Error(`unexpected /api/say content-type: ${ct}`); - } else { - writeFileSync(out, Buffer.from(await res.arrayBuffer())); + // Retry transient network / 5xx failures (the essay is many small calls; + // one dropped connection shouldn't sink the whole run). + const ATTEMPTS = 4; + let lastErr; + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + const res = await fetch(SAY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + redirect: "follow", + }); + if (!res.ok) { + if (res.status >= 500 && attempt < ATTEMPTS) throw new Error(`HTTP ${res.status}`); + process.stdout.write(" ✗\n"); + throw new Error(`/api/say ${res.status}: ${(await res.text()).slice(0, 200)}`); + } + const ct = res.headers.get("content-type") || ""; + if (!ct.includes("audio") && !ct.includes("octet-stream")) { + const j = await res.json().catch(() => null); + if (j && j.audio) writeFileSync(out, Buffer.from(j.audio, "base64")); + else throw new Error(`unexpected /api/say content-type: ${ct}`); + } else { + writeFileSync(out, Buffer.from(await res.arrayBuffer())); + } + lastErr = null; + break; + } catch (e) { + lastErr = e; + if (attempt < ATTEMPTS) { + process.stdout.write(` retry${attempt}`); + await new Promise((r) => setTimeout(r, 800 * attempt)); + } + } } + if (lastErr) { process.stdout.write(" ✗\n"); throw lastErr; } process.stdout.write(" ✓\n"); return out; } @@ -120,18 +137,59 @@ const script = essayToScript(essayPath); const speaker = script.author.replace(/^@/, ""); console.log(`\n▸ ${script.title} — ${script.paragraphs.length} paragraphs · ${script.wordCount} words · voice ${VOICE.provider}/${VOICE.voice}\n`); -// 1. Narrate the body, paragraph by paragraph. +// ── beat grid (shared with the bed) ──────────────────────────────────── +const BEAT = 60 / BED_BPM; // seconds per beat +const BAR = BEAT * 4; +const BEAT_ALIGN = !flags.nobeatalign && !flags.nobed; +// Snap a time up to the next grid line, after a minimum advance. +const snapUp = (t, grid, minAdvance) => Math.ceil((t + minAdvance) / grid - 1e-6) * grid; + +// Split a paragraph into sentences (the utterances we place on the grid). +function sentences(p) { + const parts = p.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean); + const out = []; + for (const s of parts) { + if (out.length && s.length < 22) out[out.length - 1] += " " + s; // glue tiny fragments + else out.push(s); + } + return out; +} + +// 1. Narrate the body, one sentence at a time (each an utterance to place). console.log("Narrating body…"); -const paraFiles = []; -for (let i = 0; i < script.paragraphs.length; i++) { - paraFiles.push(await say(script.paragraphs[i], `¶${i + 1}/${script.paragraphs.length}`)); +const units = []; // { text, mp3, dur, paraEnd } +for (let pi = 0; pi < script.paragraphs.length; pi++) { + const ss = sentences(script.paragraphs[pi]); + for (let si = 0; si < ss.length; si++) { + const mp3 = await say(ss[si], `¶${pi + 1}.${si + 1}`); + units.push({ text: ss[si], mp3, dur: dur(mp3), paraEnd: si === ss.length - 1 }); + } } -// 2. Measure the real reading length (body VO + inter-paragraph breaths). -const PARA_GAP = 0.55; -const bodySec = paraFiles.reduce((s, f) => s + dur(f), 0) + PARA_GAP * (paraFiles.length - 1); +// 2. Arrange the body on the beat grid: each sentence onset snaps to a beat +// (paragraph breaks snap to the bar for a longer rest). Gaps flex; words don't. +// bodyStart is treated as an on-grid origin (0) here; the assembler places the +// body on a real bar boundary so absolute onsets land on beats. +const gapsBefore = [0]; // silence before unit i (index 0 = none) +{ + let t = units[0].dur; + for (let i = 1; i < units.length; i++) { + const prev = units[i - 1]; + let g; + if (BEAT_ALIGN) { + const grid = prev.paraEnd ? BAR : BEAT; + const minBreath = prev.paraEnd ? 0.5 : 0.26; + g = snapUp(t, grid, minBreath) - t; + } else { + g = prev.paraEnd ? 0.7 : 0.4; + } + gapsBefore.push(g); + t += g + units[i].dur; + } +} +const bodySec = units.reduce((s, u) => s + u.dur, 0) + gapsBefore.reduce((a, b) => a + b, 0); const lengthText = fmtLength(bodySec); -console.log(`\nBody runs ${bodySec.toFixed(1)}s → announcing "${lengthText}".\n`); +console.log(`\n${units.length} utterances · body ${bodySec.toFixed(1)}s${BEAT_ALIGN ? ` · beat-aligned @ ${BED_BPM}bpm` : ""} → announcing "${lengthText}".\n`); // 3. Intro + outro voice-over (liturgical framing), with the measured length. const introText = `A reading of the essay: ${script.title}, by ${speaker}. Approximately ${lengthText}.`; @@ -151,18 +209,20 @@ mkdirSync(build, { recursive: true }); const seq = []; let n = 0; -const add = (src) => { const d = resolve(build, `seg_${String(n++).padStart(3, "0")}.wav`); toWav(src, d); seq.push(d); }; -const gap = (s) => { const d = resolve(build, `seg_${String(n++).padStart(3, "0")}.wav`); silence(d, s); seq.push(d); }; +let clock = 0; // absolute seconds, so we can land the body on the bed's grid +const add = (src) => { const d = resolve(build, `seg_${String(n++).padStart(3, "0")}.wav`); toWav(src, d); seq.push(d); clock += dur(d); }; +const gap = (s) => { if (s <= 0) return; const d = resolve(build, `seg_${String(n++).padStart(3, "0")}.wav`); silence(d, s); seq.push(d); clock += s; }; add(introJingle); gap(0.35); add(introVo); -gap(0.8); -for (let i = 0; i < paraFiles.length; i++) { - add(paraFiles[i]); - if (i < paraFiles.length - 1) gap(PARA_GAP); +// Push the body to the next bar boundary so every sentence onset lands on a beat. +gap((BEAT_ALIGN ? snapUp(clock, BAR, 0.6) : clock + 0.8) - clock); +for (let i = 0; i < units.length; i++) { + gap(gapsBefore[i]); // gapsBefore[0] = 0 + add(units[i].mp3); } -gap(0.8); +gap((BEAT_ALIGN ? snapUp(clock, BAR, 0.5) : clock + 0.8) - clock); add(outroVo); gap(0.3); add(outroJingle); @@ -173,15 +233,49 @@ writeFileSync(listFile, seq.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).joi const outMp3 = resolve(ROOT, "out", `${script.slug}.mp3`); mkdirSync(dirname(outMp3), { recursive: true }); -// 5a. Mix down the audio (loudnorm → mp3) to a temp file. -const audioTmp = resolve(build, "audio.mp3"); +// 5a. Concat the voice track (no normalization yet). +const voiceWav = resolve(build, "voice.wav"); execFileSync("ffmpeg", [ "-y", "-f", "concat", "-safe", "0", "-i", listFile, - "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", - "-c:a", "libmp3lame", "-b:a", "256k", - audioTmp, + "-ar", "44100", "-ac", "2", "-c:a", "pcm_s16le", voiceWav, ], { stdio: "ignore" }); +// 5b. Score a lo-fi bed (melody + rhythm) under the whole reading, +// sidechain-ducked by the voice so speech stays clear. --nobed to skip; +// --bedgain N to tune (default 0.35). +const audioTmp = resolve(build, "audio.mp3"); +if (flags.nobed) { + execFileSync("ffmpeg", [ + "-y", "-i", voiceWav, "-af", "loudnorm=I=-16:TP=-1.5:LRA=11", + "-c:a", "libmp3lame", "-b:a", "256k", audioTmp, + ], { stdio: "ignore" }); +} else { + console.log("Scoring bed…"); + const bedGain = flags.bedgain !== undefined ? Number(flags.bedgain) : 0.22; + const bedWav = resolve(build, "bed.wav"); + renderBed(dur(voiceWav) + 1.0, bedWav); + // Voice chain (sharper + upfront): high-pass rumble, presence + air EQ, + // gentle compression for a consistent forward level. + const vox = "highpass=f=85," + + "equalizer=f=3200:t=q:w=1.4:g=3.5," + + "equalizer=f=7000:t=q:w=1.6:g=2.5," + + "acompressor=threshold=-18dB:ratio=3:attack=6:release=180:makeup=2"; + // Bed chain: quiet, with a slow phaser sweep for movement, then ducked hard + // under the voice so speech always sits on top. + const bedfx = `volume=${bedGain},aphaser=type=t:speed=0.25:decay=0.4`; + execFileSync("ffmpeg", [ + "-y", "-i", voiceWav, "-i", bedWav, + "-filter_complex", + `[0:a]${vox}[vox];` + + `[1:a]${bedfx}[bedfx];` + + `[bedfx][vox]sidechaincompress=threshold=0.015:ratio=8:attack=5:release=340[bd];` + + `[vox][bd]amix=inputs=2:duration=first:normalize=0[m];` + + `[m]loudnorm=I=-16:TP=-1.5:LRA=11[out]`, + "-map", "[out]", + "-c:a", "libmp3lame", "-b:a", "256k", audioTmp, + ], { stdio: "ignore" }); +} + // 5b. Cover art (square, on-brand) → embed as ID3 album art + keep the file. console.log("Rendering cover…"); const { full: coverFull, embed: coverEmbed } = renderCover(script, resolve(ROOT, "out")); -- 2.51.2