/** * SCAFFOLD: ElevenLabs Sound Effects request/response per docs at * https://elevenlabs.io/docs/api-reference/sound-generation; verify against a * real ELEVENLABS_API_KEY before treating this as production-ready. * * ElevenLabs SFX backend — wraps the ElevenLabs Sound Generation endpoint: * POST https://api.elevenlabs.io/v1/sound-generation * Header: xi-api-key: * Body (JSON): { text, duration_seconds?, prompt_influence? } * Response: audio/mpeg bytes (mp3) * * The API honors a requested `duration_seconds` (0.5–22 s) and a * `prompt_influence` scalar (0–1, default 0.3). This backend: * - Resolves the API key from `input.env?.ELEVENLABS_API_KEY ?? process.env.ELEVENLABS_API_KEY`. * - Writes the returned mp3 bytes to `outputPath`. * - Returns `durationMs` derived from the requested `durationSec * 1000` * (default 2 000 ms). The API honors the requested duration so this is * a reliable estimate without requiring an ffprobe call. * - On dry-run: creates the output directory and writes a zero-byte * placeholder mp3; does NOT touch the network. * - On non-2xx or empty response body: throws VclawError('music_gen_failed') * (reusing the existing error code avoids touching the errors catalog). * * Availability: `ELEVENLABS_API_KEY` must be set and non-empty (in env or * process.env). The backend is inert without the key. */ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { VclawError } from '../errors.js'; import type { SfxBackend, SfxGenInput, SfxGenResult } from './types.js'; const ELEVENLABS_SFX_URL = 'https://api.elevenlabs.io/v1/sound-generation'; /** Default duration when neither the caller nor the API provides one. */ const DEFAULT_DURATION_SEC = 2; /** * Resolve the ElevenLabs API key from a caller-supplied env object or * process.env, preferring the caller-supplied env so library callers that * cannot mutate process.env can still authenticate. */ 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; } export const elevenLabsSfx: SfxBackend = { id: 'elevenlabs-sfx', kind: 'sfx', displayName: 'ElevenLabs Sound Effects', requiredEnv: ['ELEVENLABS_API_KEY'], requiresVertex: false, summary: 'Sound-effect generation via the ElevenLabs Sound Generation API. Returns an mp3 of the requested duration (0.5–22 s). Requires ELEVENLABS_API_KEY.', async generate(input: SfxGenInput): Promise { if (!input.text || !input.text.trim()) { throw new VclawError( 'music_gen_failed', 'ElevenLabs SFX: cannot generate a sound effect from empty text.', { backendId: 'elevenlabs-sfx' }, ); } const durationSec = input.durationSec ?? DEFAULT_DURATION_SEC; const durationMs = Math.round(durationSec * 1000); // 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-sfx' }; } const apiKey = resolveApiKey(input.env); if (!apiKey) { throw new VclawError( 'music_gen_failed', 'ElevenLabs SFX: ELEVENLABS_API_KEY is not set. Export it before generating sound effects.', { backendId: 'elevenlabs-sfx', envVar: 'ELEVENLABS_API_KEY' }, ); } const doFetch = input.fetcher ?? fetch; const requestBody: Record = { text: input.text.trim(), duration_seconds: durationSec, }; if (input.promptInfluence !== undefined) { requestBody.prompt_influence = input.promptInfluence; } const response = await doFetch(ELEVENLABS_SFX_URL, { method: 'POST', headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg', }, body: JSON.stringify(requestBody), }); if (!response.ok) { const body = await response.text().catch(() => ''); throw new VclawError( 'music_gen_failed', `ElevenLabs SFX request failed with HTTP ${response.status}.`, { backendId: 'elevenlabs-sfx', status: response.status, body: body.slice(0, 500) }, ); } const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.length === 0) { throw new VclawError( 'music_gen_failed', 'ElevenLabs SFX returned an empty audio payload.', { backendId: 'elevenlabs-sfx' }, ); } await mkdir(dirname(input.outputPath), { recursive: true }); await writeFile(input.outputPath, bytes); return { path: input.outputPath, durationMs, backendId: 'elevenlabs-sfx' }; }, };