/** * Narration (TTS) generation. * * `generateNarration` synthesizes a single narration clip for a project from a * text script via a TTS backend (from the audio-platform registry), writes the * audio to `projects//artifacts/audio/narration.wav`, and persists a * `narration.json` artifact describing it. When a `videoDurationMs` is supplied, * the artifact also embeds a pure `planNarrationFit()` plan (atempo / loop-video * timing) so a downstream assemble step can fit the narration to the video bed. * * Pure-ish: `env` and `fetcher` are threaded through to the backend so tests * run fully offline with no real keys or network. Throws `tts_failed` when no * requested/available TTS backend can run. */ import { mkdir, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { resolveProjectWorkspace } from './workspace.js'; import { appendProjectEvent } from './events.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { VclawError } from './errors.js'; import { getTtsBackend, listAvailableTtsBackends, isTtsBackendAvailable, } from './audio-platform/registry.js'; import type { TtsBackend, TtsGenResult } from './audio-platform/types.js'; import { planNarrationFit } from './assemble/narration-fit.js'; import type { NarrationFitPlan } from './assemble/narration-fit.js'; export const NARRATION_SCHEMA_VERSION = 1; export interface NarrationArtifact { schemaVersion: typeof NARRATION_SCHEMA_VERSION; projectSlug: string; generatedAt: string; backendId: string; voice?: string; text: string; /** Project-relative path to the generated narration audio. */ path: string; durationMs: number; /** * Backends that failed (in order) before `backendId` succeeded, when the * automatic fallback chain was used. Omitted when the first backend succeeded * or an explicit `--backend` was given (explicit selection never falls back). */ fallbackFrom?: string[]; /** Present only when a videoDurationMs was supplied. */ fit?: NarrationFitPlan; } export interface GenerateNarrationOptions { workspaceRoot: string; slug: string; text: string; voice?: string; /** Restrict to this backend id (defaults to the first AVAILABLE backend). */ backendId?: string; /** When given, embed a planNarrationFit() plan in the artifact. */ videoDurationMs?: number; dryRun?: boolean; env?: NodeJS.ProcessEnv; fetcher?: typeof fetch; } export function narrationArtifactPathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'narration.json'); } export function narrationAudioDirFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'audio'); } export async function readNarrationArtifact( root: string, slug: string, ): Promise { const path = narrationArtifactPathFor(root, slug); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as NarrationArtifact; } async function writeNarrationArtifact( root: string, slug: string, artifact: NarrationArtifact, ): Promise { const path = narrationArtifactPathFor(root, slug); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(path, `${JSON.stringify(artifact, null, 2)}\n`); return path; } /** * Generate a single narration clip + persist the `narration.json` artifact. * Returns the written artifact. Throws `tts_failed` when no requested/available * TTS backend can run. */ export async function generateNarration( options: GenerateNarrationOptions, ): Promise { const { workspaceRoot, slug, text } = options; const env = options.env ?? process.env; if (!text.trim()) { throw new VclawError('tts_failed', 'Cannot generate narration with empty text.', { slug }); } const available = listAvailableTtsBackends(env); // Resolve the candidate chain. An explicit `--backend` is honored strictly // (looked up + gated on availability) and NEVER falls back. Without one, we // try every available backend in registry order (gemini-tts first, then // elevenlabs-tts) so narration still succeeds when the preferred backend // fails at RUNTIME — e.g. gemini-tts 403s when the Gemini `generativelanguage` // API isn't enabled on the key's project even though the key is present. let candidates: TtsBackend[]; if (options.backendId) { const named = getTtsBackend(options.backendId); // throws on unknown id if (!isTtsBackendAvailable(named, env)) { throw new VclawError( 'tts_failed', `TTS backend '${named.id}' is not available. Set a Gemini API key (GEMINI_API_KEYS / GOOGLE_API_KEYS / GOOGLE_API_KEY) or ELEVENLABS_API_KEY.`, { slug, requested: named.id, available: available.map((b) => b.id) }, ); } candidates = [named]; } else { candidates = available; } if (candidates.length === 0) { throw new VclawError( 'tts_failed', 'No TTS backend is available. Set a Gemini API key (GEMINI_API_KEYS / GOOGLE_API_KEYS / GOOGLE_API_KEY) or ELEVENLABS_API_KEY.', { slug, requested: options.backendId ?? null, available: available.map((b) => b.id) }, ); } const workspace = resolveProjectWorkspace(slug, workspaceRoot); const projectRoot = resolve(workspace.projectDir); const audioDir = narrationAudioDirFor(workspaceRoot, slug); await mkdir(audioDir, { recursive: true }); // Try each candidate in order; the first that synthesizes wins. A failure is // recorded (for the `fallbackFrom` trail) and the next candidate is tried. let result: TtsGenResult | undefined; const failedBackends: string[] = []; const attemptErrors: Array<{ backendId: string; message: string }> = []; for (const backend of candidates) { // Extension follows the backend's actual output format (wav for gemini-tts, // mp3 for elevenlabs-tts) so the file name matches its bytes. const outputPath = join(audioDir, `narration.${backend.outputExtension ?? 'wav'}`); try { result = await backend.generate({ text, ...(options.voice ? { voice: options.voice } : {}), outputPath, ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), env, ...(options.fetcher ? { fetcher: options.fetcher } : {}), }); break; } catch (err) { failedBackends.push(backend.id); attemptErrors.push({ backendId: backend.id, message: err instanceof Error ? err.message : String(err), }); } } if (!result) { throw new VclawError( 'tts_failed', `All TTS backend(s) failed: ${attemptErrors.map((e) => `${e.backendId} (${e.message})`).join('; ')}`, { slug, attempts: attemptErrors, candidates: candidates.map((b) => b.id) }, ); } const fallbackFrom = failedBackends.length > 0 ? failedBackends : undefined; const rel = relative(projectRoot, resolve(result.path)).replaceAll('\\', '/'); let fit: NarrationFitPlan | undefined; if (options.videoDurationMs !== undefined) { fit = planNarrationFit({ voiceDurationMs: result.durationMs, videoDurationMs: options.videoDurationMs, }); } const artifact: NarrationArtifact = { schemaVersion: NARRATION_SCHEMA_VERSION, projectSlug: slug, generatedAt: new Date().toISOString(), backendId: result.backendId, ...(options.voice ? { voice: options.voice } : {}), text, path: rel, durationMs: result.durationMs, ...(fallbackFrom ? { fallbackFrom } : {}), ...(fit ? { fit } : {}), }; await writeNarrationArtifact(workspaceRoot, slug, artifact); if (existsSync(workspace.eventsPath) || existsSync(workspace.eventsDir)) { await appendProjectEvent(workspace, { type: 'narration.generated', payload: { backendId: result.backendId, durationMs: result.durationMs, dryRun: options.dryRun ?? false, ...(fallbackFrom ? { fallbackFrom } : {}), }, }); } return artifact; }