/** * ElevenLabs TTS narration backend — wraps the ElevenLabs Text-to-Speech endpoint: * POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id} * Header: xi-api-key: * Body (JSON): { text, model_id } * Response: audio/mpeg bytes (mp3) * * This exists so `vclaw video narrate` has a working backend that does NOT * depend on the Gemini `generativelanguage` API being enabled on the key's GCP * project (the gemini-tts backend 403s when it isn't). It is the fallback / * alternative TTS path, selected explicitly via `--backend elevenlabs-tts`. * * This backend: * - Resolves the API key from `input.env?.ELEVENLABS_API_KEY ?? process.env.ELEVENLABS_API_KEY`. * - Treats `input.voice` as an ElevenLabs **voice_id** (defaults to "Rachel"), * and uses the `eleven_multilingual_v2` model. * - Writes the returned mp3 bytes to `outputPath`. * - Returns `durationMs` ESTIMATED from text length (~14 chars/sec). ElevenLabs * returns compressed mp3 with no duration field, and we avoid a spawn * (ffprobe) to keep this backend pure + offline-testable. Callers that need * exact timing (e.g. narration-fit) probe the written file themselves. * - On dry-run: creates the output directory and writes a zero-byte * placeholder mp3; does NOT touch the network. * - On missing key / non-2xx / empty body: throws VclawError('tts_failed'). * * Availability (registry): `ELEVENLABS_API_KEY` set and non-empty. */ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { VclawError } from '../errors.js'; import type { TtsBackend, TtsGenInput, TtsGenResult } from './types.js'; const ELEVENLABS_TTS_BASE_URL = 'https://api.elevenlabs.io/v1/text-to-speech'; const TTS_MODEL = 'eleven_multilingual_v2'; /** Stable default ElevenLabs voice ("Rachel") when the caller passes no voice. */ const DEFAULT_VOICE_ID = '21m00Tcm4TlvDq8ikWAM'; /** Chars-per-second heuristic for duration estimation (~14 cps speech). */ const CHARS_PER_SEC = 14; function resolveApiKey(env: NodeJS.ProcessEnv | undefined): string | undefined { const key = env?.ELEVENLABS_API_KEY ?? process.env.ELEVENLABS_API_KEY; return key && key.trim() !== '' ? key.trim() : undefined; } function estimateDurationMs(text: string): number { return Math.round((Math.max(1, text.trim().length / CHARS_PER_SEC)) * 1000); } export const elevenLabsTts: TtsBackend = { id: 'elevenlabs-tts', kind: 'tts', outputExtension: 'mp3', displayName: 'ElevenLabs TTS (eleven_multilingual_v2)', requiredEnv: ['ELEVENLABS_API_KEY'], requiresVertex: false, summary: 'Single-speaker text-to-speech via the ElevenLabs API (eleven_multilingual_v2). Returns mp3. `--voice` is an ElevenLabs voice_id (default "Rachel"). Requires ELEVENLABS_API_KEY. A Gemini-free alternative to gemini-tts.', async generate(input: TtsGenInput): Promise { if (!input.text || !input.text.trim()) { throw new VclawError('tts_failed', 'Cannot synthesize narration from empty text.', { backendId: 'elevenlabs-tts', }); } const durationMs = estimateDurationMs(input.text); // Dry-run: create directory + write zero-byte placeholder; no network. if (input.dryRun) { await mkdir(dirname(input.outputPath), { recursive: true }); await writeFile(input.outputPath, new Uint8Array(0)); return { path: input.outputPath, durationMs, backendId: 'elevenlabs-tts' }; } const apiKey = resolveApiKey(input.env); if (!apiKey) { throw new VclawError( 'tts_failed', 'ElevenLabs TTS: ELEVENLABS_API_KEY is not set. Export it before generating narration.', { backendId: 'elevenlabs-tts', envVar: 'ELEVENLABS_API_KEY' }, ); } const voiceId = input.voice && input.voice.trim() ? input.voice.trim() : DEFAULT_VOICE_ID; const doFetch = input.fetcher ?? fetch; const response = await doFetch(`${ELEVENLABS_TTS_BASE_URL}/${encodeURIComponent(voiceId)}`, { method: 'POST', headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg', }, body: JSON.stringify({ text: input.text.trim(), model_id: TTS_MODEL }), }); if (!response.ok) { const body = await response.text().catch(() => ''); throw new VclawError( 'tts_failed', `ElevenLabs TTS request failed with HTTP ${response.status}.`, { backendId: 'elevenlabs-tts', status: response.status, body: body.slice(0, 500) }, ); } const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.length === 0) { throw new VclawError('tts_failed', 'ElevenLabs TTS returned an empty audio payload.', { backendId: 'elevenlabs-tts', }); } await mkdir(dirname(input.outputPath), { recursive: true }); await writeFile(input.outputPath, bytes); return { path: input.outputPath, durationMs, backendId: 'elevenlabs-tts' }; }, };