/** * Per-character dialogue audio orchestrator. * * `generateDialogueAudio` synthesizes one TTS clip per `DialogueTurn` — each * turn maps to a named character (optional per-turn voice override) — and * persists a `dialogue.json` artifact listing every clip with its project- * relative path and durationMs. * * Files are written to: * projects//artifacts/audio/dialogue--.wav * * The orchestrator is pure-ish: `env` and `fetcher` are threaded through to the * TTS 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 } 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'; export const DIALOGUE_SCHEMA_VERSION = 1; /** A single spoken line attributed to a named character. */ export interface DialogueTurn { /** Character name (used in artifact and output filename). */ name: string; /** Optional per-turn voice id forwarded to the TTS backend. */ voice?: string; /** The spoken line to synthesize. */ line: string; } export interface DialogueClip { name: string; voice?: string; line: string; /** Project-relative path to the generated audio clip. */ path: string; durationMs: number; } export interface DialogueArtifact { schemaVersion: typeof DIALOGUE_SCHEMA_VERSION; projectSlug: string; generatedAt: string; backendId: string; turns: DialogueClip[]; } export interface GenerateDialogueAudioOptions { workspaceRoot: string; slug: string; turns: DialogueTurn[]; /** Restrict to this backend id (defaults to the first AVAILABLE backend). */ backendId?: string; dryRun?: boolean; env?: NodeJS.ProcessEnv; fetcher?: typeof fetch; } export interface GenerateDialogueAudioResult { clips: { name: string; voice?: string; path: string; durationMs: number }[]; artifactPath: string; } /** Path of the persisted dialogue.json artifact for a project. */ export function dialogueArtifactPathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'dialogue.json'); } /** Directory where per-turn audio clips are written. */ export function dialogueAudioDirFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'audio'); } /** * Normalise a character name into a safe filename fragment: * - lowercase * - collapse non-alphanumeric runs to a single hyphen * - strip leading/trailing hyphens * e.g. "Dr. Smith" → "dr-smith" */ function nameSlug(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'char'; } /** * Synthesize one audio clip per turn and persist dialogue.json. * * Returns the in-memory clip list and the absolute path of the written * artifact so callers can chain into the assemble layer. * * Throws `tts_failed` when: * - no turns are provided * - a requested backend is not available * - no TTS backend is available at all * - a turn has an empty line */ export async function generateDialogueAudio( options: GenerateDialogueAudioOptions, ): Promise { const { workspaceRoot, slug } = options; const env = options.env ?? process.env; if (!options.turns || options.turns.length === 0) { throw new VclawError('tts_failed', 'Cannot generate dialogue audio with no turns.', { slug }); } const available = listAvailableTtsBackends(env); // Resolve the backend: explicit id (looked up + gated on availability), else // the first available backend. let backend = available[0]; 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).`, { slug, requested: named.id, available: available.map((b) => b.id) }, ); } backend = named; } if (!backend) { throw new VclawError( 'tts_failed', 'No TTS backend is available. Set a Gemini API key (GEMINI_API_KEYS / GOOGLE_API_KEYS / GOOGLE_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 = dialogueAudioDirFor(workspaceRoot, slug); await mkdir(audioDir, { recursive: true }); const clips: DialogueClip[] = []; for (let i = 0; i < options.turns.length; i++) { const turn = options.turns[i]; if (!turn.line || !turn.line.trim()) { throw new VclawError('tts_failed', `Dialogue turn ${i} for '${turn.name}' has an empty line.`, { slug, turnIndex: i, name: turn.name, }); } // e.g. dialogue-0-dr-smith.wav const filename = `dialogue-${i}-${nameSlug(turn.name)}.wav`; const outputPath = join(audioDir, filename); const result = await backend.generate({ text: turn.line, ...(turn.voice ? { voice: turn.voice } : {}), outputPath, ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), env, ...(options.fetcher ? { fetcher: options.fetcher } : {}), }); const rel = relative(projectRoot, resolve(result.path)).replaceAll('\\', '/'); const clip: DialogueClip = { name: turn.name, ...(turn.voice ? { voice: turn.voice } : {}), line: turn.line, path: rel, durationMs: result.durationMs, }; clips.push(clip); } const artifact: DialogueArtifact = { schemaVersion: DIALOGUE_SCHEMA_VERSION, projectSlug: slug, generatedAt: new Date().toISOString(), backendId: backend.id, turns: clips, }; const artifactPath = dialogueArtifactPathFor(workspaceRoot, slug); await mkdir(dirname(artifactPath), { recursive: true }); await writeTextFileAtomic(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`); if (existsSync(workspace.eventsPath) || existsSync(workspace.eventsDir)) { await appendProjectEvent(workspace, { type: 'dialogue.generated', payload: { backendId: backend.id, turns: clips.length, dryRun: options.dryRun ?? false, }, }); } return { clips: clips.map((c) => ({ name: c.name, ...(c.voice ? { voice: c.voice } : {}), path: c.path, durationMs: c.durationMs, })), artifactPath, }; }