返回 ppt-master
backend_edge.py
根目录 / skills / ppt-master / scripts / tts_backends / backend_edge.py
1 """edge-tts backend for narration audio generation."""
2
3 from __future__ import annotations
4
5 import os
6 import re
7 from dataclasses import dataclass
8 from pathlib import Path
9
10 from tts_backends.backend_common import publish_staged_pair, temporary_path
11
12
13 DEFAULT_SUBTITLE_MAX_CHARS = 20
14 DEFAULT_BOUNDARY_OVERLAP_TOLERANCE_MS = 100
15 _TICKS_PER_MILLISECOND = 10_000
16 _SENTENCE_END = frozenset("。!?!?")
17 _CLAUSE_END = frozenset(",,;;::")
18 _CLOSING_PUNCTUATION = frozenset('”’」』)》)"\'')
19
20
21 @dataclass(frozen=True)
22 class _MappedWord:
23 start: int
24 end: int
25 source_start: int
26 source_end: int
27
28
29 @dataclass(frozen=True)
30 class _SubtitleCue:
31 start: int
32 end: int
33 text: str
34
35
36 COMMON_VOICES = [
37 ("zh-CN", "zh-CN-XiaoxiaoNeural", "女声,普通话,清晰自然,默认推荐"),
38 ("zh-CN", "zh-CN-XiaoyiNeural", "女声,普通话,明亮"),
39 ("zh-CN", "zh-CN-YunjianNeural", "男声,普通话,稳重"),
40 ("zh-CN", "zh-CN-YunxiNeural", "男声,普通话,年轻"),
41 ("zh-CN", "zh-CN-YunxiaNeural", "男声,普通话,少年感"),
42 ("zh-CN", "zh-CN-YunyangNeural", "男声,普通话,播报感"),
43 ("zh-HK", "zh-HK-HiuGaaiNeural", "女声,粤语"),
44 ("zh-HK", "zh-HK-WanLungNeural", "男声,粤语"),
45 ("zh-TW", "zh-TW-HsiaoChenNeural", "女声,台湾普通话"),
46 ("zh-TW", "zh-TW-YunJheNeural", "男声,台湾普通话"),
47 ("en-US", "en-US-JennyNeural", "女声,美式英语"),
48 ("en-US", "en-US-GuyNeural", "男声,美式英语"),
49 ("en-GB", "en-GB-SoniaNeural", "女声,英式英语"),
50 ("en-GB", "en-GB-RyanNeural", "男声,英式英语"),
51 ]
52
53
54 def edge_output_extension() -> str:
55 return ".mp3"
56
57
58 def normalize_rate(rate: str) -> str:
59 """Normalize a user-provided rate into edge-tts format."""
60 value = rate.strip()
61 if not value:
62 return "+0%"
63 if value.endswith("%"):
64 if value[0] not in "+-":
65 return f"+{value}"
66 return value
67 if re.fullmatch(r"[+-]?\d+", value):
68 number = int(value)
69 return f"{number:+d}%"
70 return value
71
72
73 async def generate(
74 text: str,
75 output_path: Path,
76 *,
77 voice: str,
78 rate: str,
79 subtitle_path: Path | None = None,
80 subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
81 ) -> None:
82 """Generate narration audio and, when requested, its compact SRT."""
83 if subtitle_path is not None:
84 await _generate_with_subtitles(
85 text,
86 output_path,
87 subtitle_path,
88 voice=voice,
89 rate=rate,
90 max_chars=subtitle_max_chars,
91 )
92 return
93
94 try:
95 import edge_tts
96 except ImportError as exc:
97 raise RuntimeError(
98 "Missing dependency `edge-tts`. Install it with: "
99 "python3 -m pip install edge-tts"
100 ) from exc
101
102 communicate = edge_tts.Communicate(text, voice=voice, rate=normalize_rate(rate))
103 await communicate.save(str(output_path))
104
105
106 def _text_key(text: str) -> str:
107 return "".join(character.casefold() for character in text if character.isalnum())
108
109
110 def _source_key_positions(text: str) -> tuple[str, list[int]]:
111 key: list[str] = []
112 positions: list[int] = []
113 for index, character in enumerate(text):
114 if not character.isalnum():
115 continue
116 normalized = character.casefold()
117 key.extend(normalized)
118 positions.extend([index] * len(normalized))
119 return "".join(key), positions
120
121
122 def _map_word_boundaries(
123 text: str,
124 boundaries: list[dict],
125 provider_label: str,
126 ) -> list[_MappedWord]:
127 source_key, source_positions = _source_key_positions(text)
128 boundary_keys = [_text_key(boundary["text"]) for boundary in boundaries]
129 boundary_key = "".join(boundary_keys)
130 if not source_key or source_key != boundary_key:
131 raise RuntimeError(
132 f"{provider_label} word boundaries could not be aligned with the narration text; "
133 "subtitle timing was not generated"
134 )
135
136 mapped: list[_MappedWord] = []
137 key_offset = 0
138 for boundary, word_key in zip(boundaries, boundary_keys):
139 if not word_key:
140 continue
141 key_end = key_offset + len(word_key)
142 mapped.append(
143 _MappedWord(
144 start=boundary["offset"],
145 end=boundary["offset"] + boundary["duration"],
146 source_start=source_positions[key_offset],
147 source_end=source_positions[key_end - 1] + 1,
148 )
149 )
150 key_offset = key_end
151 return mapped
152
153
154 def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
155 while start < end and text[start].isspace():
156 start += 1
157 while end > start and text[end - 1].isspace():
158 end -= 1
159 return start, end
160
161
162 def _display_length(text: str, start: int, end: int) -> int:
163 return sum(not character.isspace() for character in text[start:end])
164
165
166 def _is_sentence_end(text: str, index: int) -> bool:
167 character = text[index]
168 if character in _SENTENCE_END:
169 return True
170 if character != ".":
171 return False
172 previous = text[index - 1] if index else ""
173 following = text[index + 1] if index + 1 < len(text) else ""
174 return not (previous.isdigit() and following.isdigit())
175
176
177 def _sentence_spans(text: str) -> list[tuple[int, int]]:
178 spans: list[tuple[int, int]] = []
179 start = 0
180 index = 0
181 while index < len(text):
182 if not _is_sentence_end(text, index):
183 index += 1
184 continue
185 end = index + 1
186 while end < len(text) and text[end] in _CLOSING_PUNCTUATION:
187 end += 1
188 span = _trim_span(text, start, end)
189 if span[0] < span[1]:
190 spans.append(span)
191 start = end
192 index = end
193 span = _trim_span(text, start, len(text))
194 if span[0] < span[1]:
195 spans.append(span)
196 return spans
197
198
199 def _hard_split_span(
200 text: str,
201 span: tuple[int, int],
202 words: list[_MappedWord],
203 max_chars: int,
204 ) -> list[tuple[int, int]]:
205 start, end = span
206 parts: list[tuple[int, int]] = []
207 while _display_length(text, start, end) > max_chars:
208 remaining_length = _display_length(text, start, end)
209 remaining_parts = (remaining_length + max_chars - 1) // max_chars
210 target_length = (remaining_length + remaining_parts - 1) // remaining_parts
211 candidates = [
212 (word.source_end, _display_length(text, start, word.source_end))
213 for word in words
214 if start < word.source_end < end
215 and _display_length(text, start, word.source_end) <= max_chars
216 ]
217 if candidates:
218 split_at, _ = min(
219 candidates,
220 key=lambda candidate: (
221 abs(candidate[1] - target_length),
222 -candidate[1],
223 ),
224 )
225 else:
226 split_at = next(
227 (
228 word.source_end
229 for word in words
230 if start < word.source_end < end
231 ),
232 end,
233 )
234 if split_at >= end:
235 break
236 part = _trim_span(text, start, split_at)
237 if part[0] < part[1]:
238 parts.append(part)
239 start = split_at
240 part = _trim_span(text, start, end)
241 if part[0] < part[1]:
242 parts.append(part)
243 return parts
244
245
246 def _split_sentence_span(
247 text: str,
248 sentence: tuple[int, int],
249 words: list[_MappedWord],
250 max_chars: int,
251 ) -> list[tuple[int, int]]:
252 if _display_length(text, *sentence) <= max_chars:
253 return [sentence]
254
255 start, end = sentence
256 clauses: list[tuple[int, int]] = []
257 clause_start = start
258 for index in range(start, end):
259 if text[index] not in _CLAUSE_END:
260 continue
261 clause = _trim_span(text, clause_start, index + 1)
262 if clause[0] < clause[1]:
263 clauses.append(clause)
264 clause_start = index + 1
265 clause = _trim_span(text, clause_start, end)
266 if clause[0] < clause[1]:
267 clauses.append(clause)
268
269 atoms = [
270 part
271 for clause in clauses
272 for part in _hard_split_span(text, clause, words, max_chars)
273 ]
274 merged: list[tuple[int, int]] = []
275 for atom in atoms:
276 if not merged:
277 merged.append(atom)
278 continue
279 candidate = (merged[-1][0], atom[1])
280 if _display_length(text, *candidate) <= max_chars:
281 merged[-1] = candidate
282 else:
283 merged.append(atom)
284 return merged
285
286
287 def _clamp_small_overlaps(
288 cues: list[_SubtitleCue],
289 *,
290 tolerance_ms: int = DEFAULT_BOUNDARY_OVERLAP_TOLERANCE_MS,
291 provider_label: str,
292 ) -> list[_SubtitleCue]:
293 tolerance = tolerance_ms * _TICKS_PER_MILLISECOND
294 normalized: list[_SubtitleCue] = []
295 for cue in cues:
296 if normalized and cue.start < normalized[-1].end:
297 overlap = normalized[-1].end - cue.start
298 if overlap > tolerance:
299 overlap_ms = overlap / _TICKS_PER_MILLISECOND
300 raise RuntimeError(
301 f"{provider_label} returned {overlap_ms:g} ms of overlapping "
302 "word-boundary timing; audio and subtitles were not published"
303 )
304 cue = _SubtitleCue(
305 start=normalized[-1].end,
306 end=cue.end,
307 text=cue.text,
308 )
309 if cue.end <= cue.start:
310 raise RuntimeError(
311 f"{provider_label} returned an invalid subtitle timing interval; "
312 "audio and subtitles were not published"
313 )
314 normalized.append(cue)
315 return normalized
316
317
318 def _subtitle_cues(
319 text: str,
320 boundaries: list[dict],
321 max_chars: int,
322 provider_label: str,
323 ) -> list[_SubtitleCue]:
324 if max_chars < 1:
325 raise ValueError("subtitle_max_chars must be at least 1")
326 words = _map_word_boundaries(text, boundaries, provider_label)
327 spans = [
328 span
329 for sentence in _sentence_spans(text)
330 for span in _split_sentence_span(text, sentence, words, max_chars)
331 ]
332
333 pending: list[tuple[int, int, str]] = []
334 assigned_word_indexes: list[int] = []
335 for start, end in spans:
336 matching = [
337 (index, word)
338 for index, word in enumerate(words)
339 if word.source_start >= start and word.source_end <= end
340 ]
341 if not matching:
342 continue
343 cue_text = re.sub(r"\s+", " ", text[start:end]).strip()
344 assigned_word_indexes.extend(index for index, _ in matching)
345 pending.append((matching[0][1].start, matching[-1][1].end, cue_text))
346
347 if assigned_word_indexes != list(range(len(words))):
348 raise RuntimeError(
349 f"{provider_label} word boundaries crossed subtitle split points; "
350 "subtitle timing was not generated"
351 )
352 if not pending:
353 raise RuntimeError(f"{provider_label} produced no timed subtitle cues")
354
355 cues: list[_SubtitleCue] = []
356 for index, (start, word_end, cue_text) in enumerate(pending):
357 next_start = pending[index + 1][0] if index + 1 < len(pending) else None
358 end = next_start if next_start is not None and next_start > word_end else word_end
359 cues.append(_SubtitleCue(start=start, end=end, text=cue_text))
360 cues = _clamp_small_overlaps(cues, provider_label=provider_label)
361
362 source_text = re.sub(r"\s+", "", text)
363 subtitle_text = re.sub(r"\s+", "", "".join(cue.text for cue in cues))
364 if subtitle_text != source_text:
365 raise RuntimeError(
366 f"{provider_label} subtitle text does not match the narration text; "
367 "audio and subtitles were not published"
368 )
369 if any(_display_length(cue.text, 0, len(cue.text)) > max_chars for cue in cues):
370 raise RuntimeError(
371 f"A single {provider_label} word boundary exceeds the subtitle character limit; "
372 "audio and subtitles were not published"
373 )
374 return cues
375
376
377 def _srt_timestamp(ticks: int) -> str:
378 total_milliseconds = round(ticks / 10_000)
379 hours, remainder = divmod(total_milliseconds, 3_600_000)
380 minutes, remainder = divmod(remainder, 60_000)
381 seconds, milliseconds = divmod(remainder, 1_000)
382 return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
383
384
385 def _format_srt(cues: list[_SubtitleCue]) -> str:
386 blocks = [
387 (
388 f"{index}\n"
389 f"{_srt_timestamp(cue.start)} --> {_srt_timestamp(cue.end)}\n"
390 f"{cue.text}"
391 )
392 for index, cue in enumerate(cues, 1)
393 ]
394 return "\n\n".join(blocks) + "\n"
395
396
397 def format_word_timed_srt(
398 text: str,
399 boundaries: list[dict],
400 max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
401 *,
402 provider_label: str = "Edge TTS",
403 ) -> str:
404 """Regroup provider word timings into compact, text-faithful SRT cues."""
405 return _format_srt(
406 _subtitle_cues(
407 text,
408 boundaries,
409 max_chars,
410 provider_label,
411 )
412 )
413
414
415 async def _generate_with_subtitles(
416 text: str,
417 output_path: Path,
418 subtitle_path: Path,
419 *,
420 voice: str,
421 rate: str,
422 max_chars: int,
423 ) -> None:
424 """Generate one MP3 and compact SRT from the same Edge word-timing stream."""
425 try:
426 import edge_tts
427 except ImportError as exc:
428 raise RuntimeError(
429 "Missing dependency `edge-tts`. Install it with: "
430 "python3 -m pip install edge-tts"
431 ) from exc
432
433 communicate = edge_tts.Communicate(
434 text,
435 voice=voice,
436 rate=normalize_rate(rate),
437 boundary="WordBoundary",
438 )
439 audio_descriptor = -1
440 subtitle_descriptor = -1
441 staged_audio: Path | None = None
442 staged_subtitle: Path | None = None
443 boundaries: list[dict] = []
444 received_audio = False
445 try:
446 audio_descriptor, staged_audio = temporary_path(output_path, ".tmp")
447 subtitle_descriptor, staged_subtitle = temporary_path(subtitle_path, ".tmp")
448
449 audio_stream = os.fdopen(audio_descriptor, "wb")
450 audio_descriptor = -1
451 with audio_stream:
452 async for chunk in communicate.stream():
453 if chunk["type"] == "audio":
454 audio_stream.write(chunk["data"])
455 received_audio = True
456 elif chunk["type"] == "WordBoundary":
457 boundaries.append(chunk)
458 audio_stream.flush()
459 os.fsync(audio_stream.fileno())
460
461 if not received_audio:
462 raise RuntimeError("Edge TTS returned no audio data")
463 if not boundaries:
464 raise RuntimeError("Edge TTS returned no word-boundary timing")
465 subtitle_text = format_word_timed_srt(text, boundaries, max_chars)
466
467 subtitle_stream = os.fdopen(
468 subtitle_descriptor,
469 "w",
470 encoding="utf-8",
471 newline="\n",
472 )
473 subtitle_descriptor = -1
474 with subtitle_stream:
475 subtitle_stream.write(subtitle_text)
476 subtitle_stream.flush()
477 os.fsync(subtitle_stream.fileno())
478 assert staged_audio is not None
479 assert staged_subtitle is not None
480 publish_staged_pair(
481 staged_audio,
482 output_path,
483 staged_subtitle,
484 subtitle_path,
485 )
486 finally:
487 if audio_descriptor >= 0:
488 os.close(audio_descriptor)
489 if subtitle_descriptor >= 0:
490 os.close(subtitle_descriptor)
491 if staged_audio is not None:
492 staged_audio.unlink(missing_ok=True)
493 if staged_subtitle is not None:
494 staged_subtitle.unlink(missing_ok=True)
495
496
497 def print_common_voices() -> None:
498 print("Common edge-tts voices:")
499 print("Locale Voice Notes")
500 print("------ ---------------------------- ----------------")
501 for locale, voice, notes in COMMON_VOICES:
502 print(f"{locale:<8} {voice:<29} {notes}")
503
504
505 async def print_voices(locale: str | None = None) -> None:
506 try:
507 import edge_tts
508 except ImportError as exc:
509 raise RuntimeError(
510 "Missing dependency `edge-tts`. Install it with: "
511 "python3 -m pip install edge-tts"
512 ) from exc
513
514 manager = await edge_tts.VoicesManager.create()
515 voices = manager.voices
516 if locale:
517 voices = [voice for voice in voices if voice.get("Locale") == locale]
518 for voice in sorted(voices, key=lambda item: (item.get("Locale", ""), item.get("ShortName", ""))):
519 short_name = voice.get("ShortName", "")
520 voice_locale = voice.get("Locale", "")
521 gender = voice.get("Gender", "")
522 friendly = voice.get("FriendlyName", "")
523 print(f"{voice_locale:<8} {short_name:<34} {gender:<8} {friendly}")
524
524 lines PYTHON