← snapshot
5240 bytes
/**
* Synthesises one audio clip per narration scene and measures each one, so
* the recorder can hold every shot exactly as long as its sentence takes.
*
* Engines, in order of preference:
*
* openai — the `cedar` voice, via /v1/audio/speech. Needs a key whose
* project has a TTS model enabled (gpt-4o-mini-tts or tts-1).
* Set OPENAI_API_KEY, optionally OPENAI_TTS_MODEL / TOUR_VOICE.
* say — macOS built-in speech. No key, no network, noticeably more
* synthetic. The fallback so the pipeline always produces
* something.
*
* Pick explicitly with `--engine=openai|say`, otherwise openai is tried and
* `say` is used if it is unavailable.
*/
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { SCENES } from "./narration.mjs";
const run = promisify(execFile);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(HERE, "audio");
const arg = (name, fallback) => {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.slice(name.length + 3) : fallback;
};
const VOICE = process.env.TOUR_VOICE ?? arg("voice", "cedar");
const SAY_VOICE = process.env.TOUR_SAY_VOICE ?? arg("say-voice", "Samantha");
const MODEL = process.env.OPENAI_TTS_MODEL ?? arg("model", "gpt-4o-mini-tts");
async function durationMs(file) {
const { stdout } = await run("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
file,
]);
return Math.round(Number.parseFloat(stdout.trim()) * 1000);
}
/** Returns null if the key/project cannot actually reach a TTS model. */
async function tryOpenAI(text, outFile) {
const key = process.env.OPENAI_API_KEY;
if (!key) return null;
const res = await fetch("https://api.openai.com/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
voice: VOICE,
input: text,
// A documentary read: unhurried, and not selling anything.
instructions:
"Read as a measured technical documentary narrator. Unhurried, precise, no salesmanship.",
}),
});
if (!res.ok) {
const detail = await res.text();
return { error: `${res.status} ${detail.slice(0, 180)}` };
}
await writeFile(outFile, Buffer.from(await res.arrayBuffer()));
return { ok: true };
}
async function viaSay(text, outFile) {
const aiff = `${outFile}.aiff`;
await run("say", ["-v", SAY_VOICE, "-r", "178", "-o", aiff, text]);
await run("ffmpeg", ["-y", "-loglevel", "error", "-i", aiff, "-b:a", "128k", outFile]);
await rm(aiff, { force: true });
}
async function main() {
const requested = arg("engine", "auto");
await rm(OUT, { recursive: true, force: true });
await mkdir(OUT, { recursive: true });
let engine = requested;
if (engine === "auto" || engine === "openai") {
const probe = await tryOpenAI("probe", path.join(OUT, "_probe.mp3"));
if (probe?.ok) {
engine = "openai";
} else {
const why = probe?.error ?? "no OPENAI_API_KEY";
if (requested === "openai") {
console.error(`openai engine unavailable: ${why}`);
process.exit(1);
}
console.warn(`! cedar/openai unavailable (${why})`);
console.warn(`! falling back to macOS 'say' voice ${SAY_VOICE}`);
engine = "say";
}
await rm(path.join(OUT, "_probe.mp3"), { force: true });
}
console.log(
`narrating ${SCENES.length} scenes with ${engine}${engine === "openai" ? ` (${VOICE}/${MODEL})` : ` (${SAY_VOICE})`}`,
);
const manifest = [];
for (const [i, scene] of SCENES.entries()) {
const file = path.join(OUT, `${String(i).padStart(2, "0")}-${scene.id}.mp3`);
if (engine === "openai") {
const r = await tryOpenAI(scene.text, file);
if (!r?.ok) throw new Error(`scene ${scene.id}: ${r?.error ?? "failed"}`);
} else {
await viaSay(scene.text, file);
}
const ms = await durationMs(file);
manifest.push({ id: scene.id, file: path.basename(file), ms, text: scene.text });
console.log(` ${scene.id.padEnd(12)} ${String(ms).padStart(6)}ms`);
}
const total = manifest.reduce((n, m) => n + m.ms, 0);
await writeFile(
path.join(OUT, "manifest.json"),
`${JSON.stringify({ engine, voice: engine === "openai" ? VOICE : SAY_VOICE, scenes: manifest }, null, 2)}\n`,
);
console.log(`total narration: ${(total / 1000).toFixed(1)}s -> ${OUT}/manifest.json`);
}
// Only synthesise when run directly — tour.mjs imports `loadManifest` from
// here, and an unguarded main() would re-narrate the whole script on import.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}
export { durationMs };
export const manifestPath = path.join(OUT, "manifest.json");
export async function loadManifest() {
return JSON.parse(await readFile(manifestPath, "utf8"));
}