jacquardSnapshot

← 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"));
}