diff --git a/recap/bin/tts.mjs b/recap/bin/tts.mjs index 66dc060336..75a5e5ed5f 100755 --- a/recap/bin/tts.mjs +++ b/recap/bin/tts.mjs @@ -1,14 +1,25 @@ #!/usr/bin/env node // tts.mjs — POST audience narration to /api/say, save MP3 to out/recap.mp3. -// Usage: node bin/tts.mjs [audience-name] (default: fia) +// +// Caching: keyed on a content hash of (narration text + voice provider + +// voice id). If `out/recap.mp3` exists AND `out/recap.mp3.hash` matches +// the current input hash, skip the /api/say call entirely (which costs +// real money via ElevenLabs proxying). Pass `--force` to bypass. +// +// Usage: +// node bin/tts.mjs [audience-name] (default: fia) +// node bin/tts.mjs jeffrey-73h-2026-05-02 --force -import { writeFileSync } from "node:fs"; +import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, ".."); -const audienceName = process.argv[2] || "fia"; +const argv = process.argv.slice(2); +const force = argv.includes("--force"); +const audienceName = argv.find((a) => !a.startsWith("--")) || "fia"; const { audience } = await import(`${ROOT}/audience/${audienceName}.mjs`); @@ -18,6 +29,26 @@ const body = { voice: audience.voice.voice, }; +const inputHash = createHash("sha256") + .update(JSON.stringify(body)) + .digest("hex") + .slice(0, 16); + +const out = `${ROOT}/out/recap.mp3`; +const hashFile = `${out}.hash`; +mkdirSync(`${ROOT}/out`, { recursive: true }); + +// Skip if cached output matches the current input hash. ElevenLabs is +// the only real-money step here, so skipping saves ~$ on every rerun. +if (!force && existsSync(out) && existsSync(hashFile)) { + const cached = readFileSync(hashFile, "utf8").trim(); + if (cached === inputHash) { + const size = (readFileSync(out).length / 1024).toFixed(0); + console.log(`✓ ${out} cached (${size} KB · hash ${inputHash}) — skipping /api/say`); + process.exit(0); + } +} + console.log(`→ POST /api/say · ${audience.narration.length} chars · ${audience.voice.provider}`); const res = await fetch("https://aesthetic.computer/api/say", { method: "POST", @@ -32,6 +63,6 @@ if (!res.ok) { } const buf = Buffer.from(await res.arrayBuffer()); -const out = `${ROOT}/out/recap.mp3`; writeFileSync(out, buf); -console.log(`✓ ${out} (${(buf.length / 1024).toFixed(0)} KB)`); +writeFileSync(hashFile, inputHash + "\n"); +console.log(`✓ ${out} (${(buf.length / 1024).toFixed(0)} KB · hash ${inputHash})`);