// Pre-generates the Academy narration MP3s with Azure TTS and writes them to // public/academy-audio/. Run locally (the key never ships): reads AZURE_VOICE_KEY and // AZURE_VOICE from .env, region eastus. Idempotent — pass --force to regenerate everything; // otherwise only missing slide audio is synthesized. The spoken text is slide.tts (phonetic // respelling) when present, else the display transcript. import { readFile, writeFile, mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const { ACADEMY_TRACKS } = await import(path.join(root, "public/js/academy-content.js")); const env = Object.fromEntries( (await readFile(path.join(root, ".env"), "utf8")) .split("\n") .filter((line) => line.includes("=")) .map((line) => { const i = line.indexOf("="); return [line.slice(0, i).trim(), line.slice(i + 1).trim().replace(/^"|"$/g, "")]; }), ); const KEY = env.AZURE_VOICE_KEY; const VOICE = env.AZURE_VOICE; if (!KEY || !VOICE) throw new Error("AZURE_VOICE_KEY / AZURE_VOICE missing from .env"); const outDir = path.join(root, "public", "academy-audio"); await mkdir(outDir, { recursive: true }); const force = process.argv.includes("--force"); const escapeXml = (s) => s.replace(/&/g, "&").replace(//g, ">"); const slides = []; const seen = new Set(); for (const track of ACADEMY_TRACKS) for (const lesson of track.lessons) for (const slide of lesson.slides) { if (seen.has(slide.id)) throw new Error(`duplicate slide id ${slide.id}`); seen.add(slide.id); slides.push(slide); } let made = 0; let skipped = 0; for (const slide of slides) { const file = path.join(outDir, `${slide.id}.mp3`); if (!force && existsSync(file)) { skipped++; continue; } const ssml = `${escapeXml(slide.tts ?? slide.transcript)}`; const res = await fetch("https://eastus.tts.speech.microsoft.com/cognitiveservices/v1", { method: "POST", headers: { "Ocp-Apim-Subscription-Key": KEY, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "audio-24khz-48kbitrate-mono-mp3", "User-Agent": "roast-academy", }, body: ssml, }); if (!res.ok) throw new Error(`TTS failed for ${slide.id}: ${res.status} ${await res.text()}`); const buffer = Buffer.from(await res.arrayBuffer()); if (buffer.length < 2_000) throw new Error(`suspiciously small audio for ${slide.id}`); await writeFile(file, buffer); made++; console.log(`✓ ${slide.id} (${Math.round(buffer.length / 1024)} KB)`); // Be polite to the TTS quota. await new Promise((resolve) => setTimeout(resolve, 400)); } console.log(`Done: ${made} generated, ${skipped} already present, ${slides.length} total.`);