| 1 | """ElevenLabs backend for narration audio generation.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | from pathlib import Path |
| 7 | from urllib import error, request |
| 8 | |
| 9 | from tts_backends.backend_common import publish_audio_bytes, read_api_key |
| 10 | |
| 11 | |
| 12 | API_BASE = "https://api.elevenlabs.io/v1" |
| 13 | |
| 14 | |
| 15 | def read_elevenlabs_api_key(env_name: str) -> str: |
| 16 | return read_api_key(env_name, label="ElevenLabs") |
| 17 | |
| 18 | |
| 19 | def output_extension(output_format: str) -> str: |
| 20 | codec = output_format.split("_", 1)[0].lower() |
| 21 | if codec in {"mp3", "wav"}: |
| 22 | return f".{codec}" |
| 23 | raise RuntimeError( |
| 24 | f"Unsupported ElevenLabs output format for PPT narration: {output_format}. " |
| 25 | "Use an mp3_* or wav_* format." |
| 26 | ) |
| 27 | |
| 28 | |
| 29 | def _read_http_error(exc: error.HTTPError) -> str: |
| 30 | try: |
| 31 | body = exc.read().decode("utf-8", errors="replace") |
| 32 | except Exception: |
| 33 | body = "" |
| 34 | return f"HTTP {exc.code}: {body or exc.reason}" |
| 35 | |
| 36 | |
| 37 | def generate( |
| 38 | text: str, |
| 39 | output_path: Path, |
| 40 | *, |
| 41 | api_key: str, |
| 42 | voice_id: str, |
| 43 | model: str, |
| 44 | output_format: str, |
| 45 | stability: float | None, |
| 46 | similarity_boost: float | None, |
| 47 | style: float | None, |
| 48 | speaker_boost: bool | None, |
| 49 | ) -> None: |
| 50 | payload: dict[str, object] = { |
| 51 | "text": text, |
| 52 | "model_id": model, |
| 53 | } |
| 54 | |
| 55 | voice_settings: dict[str, object] = {} |
| 56 | if stability is not None: |
| 57 | voice_settings["stability"] = stability |
| 58 | if similarity_boost is not None: |
| 59 | voice_settings["similarity_boost"] = similarity_boost |
| 60 | if style is not None: |
| 61 | voice_settings["style"] = style |
| 62 | if speaker_boost is not None: |
| 63 | voice_settings["use_speaker_boost"] = speaker_boost |
| 64 | if voice_settings: |
| 65 | payload["voice_settings"] = voice_settings |
| 66 | |
| 67 | body = json.dumps(payload, ensure_ascii=False).encode("utf-8") |
| 68 | url = f"{API_BASE}/text-to-speech/{voice_id}?output_format={output_format}" |
| 69 | req = request.Request( |
| 70 | url, |
| 71 | data=body, |
| 72 | headers={ |
| 73 | "Content-Type": "application/json", |
| 74 | "xi-api-key": api_key, |
| 75 | }, |
| 76 | method="POST", |
| 77 | ) |
| 78 | try: |
| 79 | with request.urlopen(req, timeout=120) as response: |
| 80 | publish_audio_bytes(response.read(), output_path) |
| 81 | except error.HTTPError as exc: |
| 82 | raise RuntimeError(_read_http_error(exc)) from exc |
| 83 | except error.URLError as exc: |
| 84 | raise RuntimeError(f"ElevenLabs request failed: {exc.reason}") from exc |
| 85 | |
| 86 | |
| 87 | def print_voices(api_key: str) -> None: |
| 88 | req = request.Request( |
| 89 | f"{API_BASE}/voices", |
| 90 | headers={"xi-api-key": api_key}, |
| 91 | method="GET", |
| 92 | ) |
| 93 | try: |
| 94 | with request.urlopen(req, timeout=60) as response: |
| 95 | data = json.loads(response.read().decode("utf-8")) |
| 96 | except error.HTTPError as exc: |
| 97 | raise RuntimeError(_read_http_error(exc)) from exc |
| 98 | except error.URLError as exc: |
| 99 | raise RuntimeError(f"ElevenLabs request failed: {exc.reason}") from exc |
| 100 | |
| 101 | print("ElevenLabs voices:") |
| 102 | print("Voice ID Name Category") |
| 103 | print("----------------------- ----------------------------- ----------") |
| 104 | for item in data.get("voices", []): |
| 105 | voice_id = item.get("voice_id", "") |
| 106 | name = item.get("name", "") |
| 107 | category = item.get("category", "") |
| 108 | print(f"{voice_id:<23} {name:<29} {category}") |
| 109 |