import { execFile, execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); let sayChecked = false; let sayAvailable = false; function hasSay() { if (!sayChecked) { sayChecked = true; try { execFileSync("say", ["-v", "?"], { stdio: "ignore" }); sayAvailable = true; } catch { console.warn("`say` not found — skipping TTS narration (subtitles still work)."); } } return sayAvailable; } const cache = new Map>(); /** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */ export function synthesize( text: string, outDir: string, index: number, voice = "Paulina", ): Promise<{ path: string; durationMs: number } | null> { const key = `${voice}:${text}`; const cached = cache.get(key); if (cached) return cached; const promise = (async () => { if (!hasSay()) return null; fs.mkdirSync(outDir, { recursive: true }); const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`); try { await execFileAsync("say", ["-v", voice, "-o", file, text]); const { stdout } = await execFileAsync("ffprobe", [ "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file, ]); const durationMs = Math.round(parseFloat(stdout.trim()) * 1000); return { path: file, durationMs }; } catch (err) { console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`); fs.rmSync(file, { force: true }); return null; } })(); cache.set(key, promise); return promise; }