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