返回 ppt-master
backend_elevenlabs.py
根目录 / skills / ppt-master / scripts / tts_backends / backend_elevenlabs.py
1 """ElevenLabs backend for narration audio generation."""
2
3 from __future__ import annotations
4
5 import base64
6 import binascii
7 import json
8 import math
9 from pathlib import Path
10 from urllib import error, request
11 from urllib.parse import quote, urlencode
12
13 from tts_backends.backend_common import (
14 publish_audio_bytes,
15 publish_audio_subtitle,
16 read_api_key,
17 )
18 from tts_backends.backend_edge import (
19 DEFAULT_SUBTITLE_MAX_CHARS,
20 format_word_timed_srt,
21 )
22
23
24 API_BASE = "https://api.elevenlabs.io/v1"
25 _TICKS_PER_SECOND = 10_000_000
26
27
28 def read_elevenlabs_api_key(env_name: str) -> str:
29 return read_api_key(env_name, label="ElevenLabs")
30
31
32 def output_extension(output_format: str) -> str:
33 codec = output_format.split("_", 1)[0].lower()
34 if codec in {"mp3", "wav"}:
35 return f".{codec}"
36 raise RuntimeError(
37 f"Unsupported ElevenLabs output format for PPT narration: {output_format}. "
38 "Use an mp3_* or wav_* format."
39 )
40
41
42 def _read_http_error(exc: error.HTTPError) -> str:
43 try:
44 body = exc.read().decode("utf-8", errors="replace")
45 except Exception:
46 body = ""
47 return f"HTTP {exc.code}: {body or exc.reason}"
48
49
50 def _seconds_to_ticks(value: object, *, field: str) -> int:
51 if isinstance(value, bool):
52 raise RuntimeError(f"ElevenLabs alignment {field} is not numeric")
53 try:
54 numeric = float(value)
55 except (TypeError, ValueError) as exc:
56 raise RuntimeError(
57 f"ElevenLabs alignment {field} is not numeric"
58 ) from exc
59 if not math.isfinite(numeric):
60 raise RuntimeError(f"ElevenLabs alignment {field} is not finite")
61 return int(round(numeric * _TICKS_PER_SECOND))
62
63
64 def _is_unspaced_character(character: str) -> bool:
65 """Return whether a script is safer to time one character at a time."""
66 codepoint = ord(character)
67 return (
68 0x3040 <= codepoint <= 0x30FF
69 or 0x3400 <= codepoint <= 0x4DBF
70 or 0x4E00 <= codepoint <= 0x9FFF
71 or 0xAC00 <= codepoint <= 0xD7AF
72 or 0xF900 <= codepoint <= 0xFAFF
73 or 0x20000 <= codepoint <= 0x2FA1F
74 )
75
76
77 def _alignment_characters(
78 text: str,
79 alignment: object,
80 ) -> tuple[list[str], list[object], list[object]]:
81 if not isinstance(alignment, dict):
82 raise RuntimeError("ElevenLabs response contains no original-text alignment")
83 raw_characters = alignment.get("characters")
84 raw_starts = alignment.get("character_start_times_seconds")
85 raw_ends = alignment.get("character_end_times_seconds")
86 if not all(isinstance(values, list) for values in (
87 raw_characters,
88 raw_starts,
89 raw_ends,
90 )):
91 raise RuntimeError("ElevenLabs alignment arrays are missing")
92 if not raw_characters or not (
93 len(raw_characters) == len(raw_starts) == len(raw_ends)
94 ):
95 raise RuntimeError("ElevenLabs alignment arrays have inconsistent lengths")
96 if any(not isinstance(item, str) or not item for item in raw_characters):
97 raise RuntimeError("ElevenLabs alignment contains an invalid character")
98 if "".join(raw_characters) != text:
99 raise RuntimeError(
100 "ElevenLabs original-text alignment differs from the narration text; "
101 "audio and subtitles were not published"
102 )
103
104 characters: list[str] = []
105 starts: list[object] = []
106 ends: list[object] = []
107 for raw_character, start, end in zip(
108 raw_characters,
109 raw_starts,
110 raw_ends,
111 ):
112 for character in raw_character:
113 characters.append(character)
114 starts.append(start)
115 ends.append(end)
116 return characters, starts, ends
117
118
119 def _word_boundaries(text: str, alignment: object) -> list[dict]:
120 characters, starts, ends = _alignment_characters(text, alignment)
121 boundaries: list[dict] = []
122 index = 0
123 while index < len(characters):
124 character = characters[index]
125 if not character.isalnum():
126 index += 1
127 continue
128
129 token_end = index + 1
130 if not _is_unspaced_character(character):
131 while token_end < len(characters):
132 following = characters[token_end]
133 if (
134 not following.isalnum()
135 or _is_unspaced_character(following)
136 ):
137 break
138 token_end += 1
139
140 start = _seconds_to_ticks(starts[index], field="start time")
141 end = _seconds_to_ticks(ends[token_end - 1], field="end time")
142 if start < 0 or end <= start:
143 raise RuntimeError(
144 "ElevenLabs alignment contains an invalid character interval"
145 )
146 if boundaries and start < boundaries[-1]["offset"]:
147 raise RuntimeError(
148 "ElevenLabs alignment is not in chronological order"
149 )
150 boundaries.append({
151 "text": "".join(characters[index:token_end]),
152 "offset": start,
153 "duration": end - start,
154 })
155 index = token_end
156
157 if not boundaries:
158 raise RuntimeError("ElevenLabs alignment contains no timed words")
159 return boundaries
160
161
162 def generate(
163 text: str,
164 output_path: Path,
165 *,
166 api_key: str,
167 voice_id: str,
168 model: str,
169 output_format: str,
170 stability: float | None,
171 similarity_boost: float | None,
172 style: float | None,
173 speed: float | None,
174 speaker_boost: bool | None,
175 subtitle_path: Path | None = None,
176 subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
177 ) -> None:
178 payload: dict[str, object] = {
179 "text": text,
180 "model_id": model,
181 }
182
183 voice_settings: dict[str, object] = {}
184 if stability is not None:
185 voice_settings["stability"] = stability
186 if similarity_boost is not None:
187 voice_settings["similarity_boost"] = similarity_boost
188 if style is not None:
189 voice_settings["style"] = style
190 if speed is not None:
191 voice_settings["speed"] = speed
192 if speaker_boost is not None:
193 voice_settings["use_speaker_boost"] = speaker_boost
194 if voice_settings:
195 payload["voice_settings"] = voice_settings
196
197 query = urlencode({"output_format": output_format})
198 suffix = "/with-timestamps" if subtitle_path is not None else ""
199 url = (
200 f"{API_BASE}/text-to-speech/{quote(voice_id, safe='')}{suffix}?{query}"
201 )
202 body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
203 req = request.Request(
204 url,
205 data=body,
206 headers={
207 "Content-Type": "application/json",
208 "xi-api-key": api_key,
209 },
210 method="POST",
211 )
212 try:
213 with request.urlopen(req, timeout=120) as response:
214 response_data = response.read()
215 except error.HTTPError as exc:
216 raise RuntimeError(_read_http_error(exc)) from exc
217 except error.URLError as exc:
218 raise RuntimeError(f"ElevenLabs request failed: {exc.reason}") from exc
219
220 if subtitle_path is None:
221 publish_audio_bytes(response_data, output_path)
222 return
223
224 try:
225 data = json.loads(response_data.decode("utf-8"))
226 except (UnicodeError, json.JSONDecodeError) as exc:
227 raise RuntimeError(
228 "ElevenLabs timing response is not valid UTF-8 JSON"
229 ) from exc
230 encoded_audio = data.get("audio_base64")
231 if not isinstance(encoded_audio, str) or not encoded_audio:
232 raise RuntimeError("ElevenLabs timing response contains no audio")
233 try:
234 audio = base64.b64decode(encoded_audio, validate=True)
235 except (binascii.Error, ValueError) as exc:
236 raise RuntimeError(
237 "ElevenLabs timing response audio is not valid base64"
238 ) from exc
239 subtitle = format_word_timed_srt(
240 text,
241 _word_boundaries(text, data.get("alignment")),
242 subtitle_max_chars,
243 provider_label="ElevenLabs",
244 )
245 publish_audio_subtitle(audio, output_path, subtitle, subtitle_path)
246
247
248 def print_voices(api_key: str) -> None:
249 req = request.Request(
250 f"{API_BASE}/voices",
251 headers={"xi-api-key": api_key},
252 method="GET",
253 )
254 try:
255 with request.urlopen(req, timeout=60) as response:
256 data = json.loads(response.read().decode("utf-8"))
257 except error.HTTPError as exc:
258 raise RuntimeError(_read_http_error(exc)) from exc
259 except error.URLError as exc:
260 raise RuntimeError(f"ElevenLabs request failed: {exc.reason}") from exc
261
262 print("ElevenLabs voices:")
263 print("Voice ID Name Category")
264 print("----------------------- ----------------------------- ----------")
265 for item in data.get("voices", []):
266 voice_id = item.get("voice_id", "")
267 name = item.get("name", "")
268 category = item.get("category", "")
269 print(f"{voice_id:<23} {name:<29} {category}")
270
270 lines PYTHON