Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,523 @@
"""edge-tts backend for narration audio generation."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from tts_backends.backend_common import publish_staged_pair, temporary_path
DEFAULT_SUBTITLE_MAX_CHARS = 20
DEFAULT_BOUNDARY_OVERLAP_TOLERANCE_MS = 100
_TICKS_PER_MILLISECOND = 10_000
_SENTENCE_END = frozenset("。!?!?")
_CLAUSE_END = frozenset(",,;;::")
_CLOSING_PUNCTUATION = frozenset('”’」』)》)"\'')
@dataclass(frozen=True)
class _MappedWord:
start: int
end: int
source_start: int
source_end: int
@dataclass(frozen=True)
class _SubtitleCue:
start: int
end: int
text: str
COMMON_VOICES = [
("zh-CN", "zh-CN-XiaoxiaoNeural", "女声,普通话,清晰自然,默认推荐"),
("zh-CN", "zh-CN-XiaoyiNeural", "女声,普通话,明亮"),
("zh-CN", "zh-CN-YunjianNeural", "男声,普通话,稳重"),
("zh-CN", "zh-CN-YunxiNeural", "男声,普通话,年轻"),
("zh-CN", "zh-CN-YunxiaNeural", "男声,普通话,少年感"),
("zh-CN", "zh-CN-YunyangNeural", "男声,普通话,播报感"),
("zh-HK", "zh-HK-HiuGaaiNeural", "女声,粤语"),
("zh-HK", "zh-HK-WanLungNeural", "男声,粤语"),
("zh-TW", "zh-TW-HsiaoChenNeural", "女声,台湾普通话"),
("zh-TW", "zh-TW-YunJheNeural", "男声,台湾普通话"),
("en-US", "en-US-JennyNeural", "女声,美式英语"),
("en-US", "en-US-GuyNeural", "男声,美式英语"),
("en-GB", "en-GB-SoniaNeural", "女声,英式英语"),
("en-GB", "en-GB-RyanNeural", "男声,英式英语"),
]
def edge_output_extension() -> str:
return ".mp3"
def normalize_rate(rate: str) -> str:
"""Normalize a user-provided rate into edge-tts format."""
value = rate.strip()
if not value:
return "+0%"
if value.endswith("%"):
if value[0] not in "+-":
return f"+{value}"
return value
if re.fullmatch(r"[+-]?\d+", value):
number = int(value)
return f"{number:+d}%"
return value
async def generate(
text: str,
output_path: Path,
*,
voice: str,
rate: str,
subtitle_path: Path | None = None,
subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
) -> None:
"""Generate narration audio and, when requested, its compact SRT."""
if subtitle_path is not None:
await _generate_with_subtitles(
text,
output_path,
subtitle_path,
voice=voice,
rate=rate,
max_chars=subtitle_max_chars,
)
return
try:
import edge_tts
except ImportError as exc:
raise RuntimeError(
"Missing dependency `edge-tts`. Install it with: "
"python3 -m pip install edge-tts"
) from exc
communicate = edge_tts.Communicate(text, voice=voice, rate=normalize_rate(rate))
await communicate.save(str(output_path))
def _text_key(text: str) -> str:
return "".join(character.casefold() for character in text if character.isalnum())
def _source_key_positions(text: str) -> tuple[str, list[int]]:
key: list[str] = []
positions: list[int] = []
for index, character in enumerate(text):
if not character.isalnum():
continue
normalized = character.casefold()
key.extend(normalized)
positions.extend([index] * len(normalized))
return "".join(key), positions
def _map_word_boundaries(
text: str,
boundaries: list[dict],
provider_label: str,
) -> list[_MappedWord]:
source_key, source_positions = _source_key_positions(text)
boundary_keys = [_text_key(boundary["text"]) for boundary in boundaries]
boundary_key = "".join(boundary_keys)
if not source_key or source_key != boundary_key:
raise RuntimeError(
f"{provider_label} word boundaries could not be aligned with the narration text; "
"subtitle timing was not generated"
)
mapped: list[_MappedWord] = []
key_offset = 0
for boundary, word_key in zip(boundaries, boundary_keys):
if not word_key:
continue
key_end = key_offset + len(word_key)
mapped.append(
_MappedWord(
start=boundary["offset"],
end=boundary["offset"] + boundary["duration"],
source_start=source_positions[key_offset],
source_end=source_positions[key_end - 1] + 1,
)
)
key_offset = key_end
return mapped
def _trim_span(text: str, start: int, end: int) -> tuple[int, int]:
while start < end and text[start].isspace():
start += 1
while end > start and text[end - 1].isspace():
end -= 1
return start, end
def _display_length(text: str, start: int, end: int) -> int:
return sum(not character.isspace() for character in text[start:end])
def _is_sentence_end(text: str, index: int) -> bool:
character = text[index]
if character in _SENTENCE_END:
return True
if character != ".":
return False
previous = text[index - 1] if index else ""
following = text[index + 1] if index + 1 < len(text) else ""
return not (previous.isdigit() and following.isdigit())
def _sentence_spans(text: str) -> list[tuple[int, int]]:
spans: list[tuple[int, int]] = []
start = 0
index = 0
while index < len(text):
if not _is_sentence_end(text, index):
index += 1
continue
end = index + 1
while end < len(text) and text[end] in _CLOSING_PUNCTUATION:
end += 1
span = _trim_span(text, start, end)
if span[0] < span[1]:
spans.append(span)
start = end
index = end
span = _trim_span(text, start, len(text))
if span[0] < span[1]:
spans.append(span)
return spans
def _hard_split_span(
text: str,
span: tuple[int, int],
words: list[_MappedWord],
max_chars: int,
) -> list[tuple[int, int]]:
start, end = span
parts: list[tuple[int, int]] = []
while _display_length(text, start, end) > max_chars:
remaining_length = _display_length(text, start, end)
remaining_parts = (remaining_length + max_chars - 1) // max_chars
target_length = (remaining_length + remaining_parts - 1) // remaining_parts
candidates = [
(word.source_end, _display_length(text, start, word.source_end))
for word in words
if start < word.source_end < end
and _display_length(text, start, word.source_end) <= max_chars
]
if candidates:
split_at, _ = min(
candidates,
key=lambda candidate: (
abs(candidate[1] - target_length),
-candidate[1],
),
)
else:
split_at = next(
(
word.source_end
for word in words
if start < word.source_end < end
),
end,
)
if split_at >= end:
break
part = _trim_span(text, start, split_at)
if part[0] < part[1]:
parts.append(part)
start = split_at
part = _trim_span(text, start, end)
if part[0] < part[1]:
parts.append(part)
return parts
def _split_sentence_span(
text: str,
sentence: tuple[int, int],
words: list[_MappedWord],
max_chars: int,
) -> list[tuple[int, int]]:
if _display_length(text, *sentence) <= max_chars:
return [sentence]
start, end = sentence
clauses: list[tuple[int, int]] = []
clause_start = start
for index in range(start, end):
if text[index] not in _CLAUSE_END:
continue
clause = _trim_span(text, clause_start, index + 1)
if clause[0] < clause[1]:
clauses.append(clause)
clause_start = index + 1
clause = _trim_span(text, clause_start, end)
if clause[0] < clause[1]:
clauses.append(clause)
atoms = [
part
for clause in clauses
for part in _hard_split_span(text, clause, words, max_chars)
]
merged: list[tuple[int, int]] = []
for atom in atoms:
if not merged:
merged.append(atom)
continue
candidate = (merged[-1][0], atom[1])
if _display_length(text, *candidate) <= max_chars:
merged[-1] = candidate
else:
merged.append(atom)
return merged
def _clamp_small_overlaps(
cues: list[_SubtitleCue],
*,
tolerance_ms: int = DEFAULT_BOUNDARY_OVERLAP_TOLERANCE_MS,
provider_label: str,
) -> list[_SubtitleCue]:
tolerance = tolerance_ms * _TICKS_PER_MILLISECOND
normalized: list[_SubtitleCue] = []
for cue in cues:
if normalized and cue.start < normalized[-1].end:
overlap = normalized[-1].end - cue.start
if overlap > tolerance:
overlap_ms = overlap / _TICKS_PER_MILLISECOND
raise RuntimeError(
f"{provider_label} returned {overlap_ms:g} ms of overlapping "
"word-boundary timing; audio and subtitles were not published"
)
cue = _SubtitleCue(
start=normalized[-1].end,
end=cue.end,
text=cue.text,
)
if cue.end <= cue.start:
raise RuntimeError(
f"{provider_label} returned an invalid subtitle timing interval; "
"audio and subtitles were not published"
)
normalized.append(cue)
return normalized
def _subtitle_cues(
text: str,
boundaries: list[dict],
max_chars: int,
provider_label: str,
) -> list[_SubtitleCue]:
if max_chars < 1:
raise ValueError("subtitle_max_chars must be at least 1")
words = _map_word_boundaries(text, boundaries, provider_label)
spans = [
span
for sentence in _sentence_spans(text)
for span in _split_sentence_span(text, sentence, words, max_chars)
]
pending: list[tuple[int, int, str]] = []
assigned_word_indexes: list[int] = []
for start, end in spans:
matching = [
(index, word)
for index, word in enumerate(words)
if word.source_start >= start and word.source_end <= end
]
if not matching:
continue
cue_text = re.sub(r"\s+", " ", text[start:end]).strip()
assigned_word_indexes.extend(index for index, _ in matching)
pending.append((matching[0][1].start, matching[-1][1].end, cue_text))
if assigned_word_indexes != list(range(len(words))):
raise RuntimeError(
f"{provider_label} word boundaries crossed subtitle split points; "
"subtitle timing was not generated"
)
if not pending:
raise RuntimeError(f"{provider_label} produced no timed subtitle cues")
cues: list[_SubtitleCue] = []
for index, (start, word_end, cue_text) in enumerate(pending):
next_start = pending[index + 1][0] if index + 1 < len(pending) else None
end = next_start if next_start is not None and next_start > word_end else word_end
cues.append(_SubtitleCue(start=start, end=end, text=cue_text))
cues = _clamp_small_overlaps(cues, provider_label=provider_label)
source_text = re.sub(r"\s+", "", text)
subtitle_text = re.sub(r"\s+", "", "".join(cue.text for cue in cues))
if subtitle_text != source_text:
raise RuntimeError(
f"{provider_label} subtitle text does not match the narration text; "
"audio and subtitles were not published"
)
if any(_display_length(cue.text, 0, len(cue.text)) > max_chars for cue in cues):
raise RuntimeError(
f"A single {provider_label} word boundary exceeds the subtitle character limit; "
"audio and subtitles were not published"
)
return cues
def _srt_timestamp(ticks: int) -> str:
total_milliseconds = round(ticks / 10_000)
hours, remainder = divmod(total_milliseconds, 3_600_000)
minutes, remainder = divmod(remainder, 60_000)
seconds, milliseconds = divmod(remainder, 1_000)
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
def _format_srt(cues: list[_SubtitleCue]) -> str:
blocks = [
(
f"{index}\n"
f"{_srt_timestamp(cue.start)} --> {_srt_timestamp(cue.end)}\n"
f"{cue.text}"
)
for index, cue in enumerate(cues, 1)
]
return "\n\n".join(blocks) + "\n"
def format_word_timed_srt(
text: str,
boundaries: list[dict],
max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
*,
provider_label: str = "Edge TTS",
) -> str:
"""Regroup provider word timings into compact, text-faithful SRT cues."""
return _format_srt(
_subtitle_cues(
text,
boundaries,
max_chars,
provider_label,
)
)
async def _generate_with_subtitles(
text: str,
output_path: Path,
subtitle_path: Path,
*,
voice: str,
rate: str,
max_chars: int,
) -> None:
"""Generate one MP3 and compact SRT from the same Edge word-timing stream."""
try:
import edge_tts
except ImportError as exc:
raise RuntimeError(
"Missing dependency `edge-tts`. Install it with: "
"python3 -m pip install edge-tts"
) from exc
communicate = edge_tts.Communicate(
text,
voice=voice,
rate=normalize_rate(rate),
boundary="WordBoundary",
)
audio_descriptor = -1
subtitle_descriptor = -1
staged_audio: Path | None = None
staged_subtitle: Path | None = None
boundaries: list[dict] = []
received_audio = False
try:
audio_descriptor, staged_audio = temporary_path(output_path, ".tmp")
subtitle_descriptor, staged_subtitle = temporary_path(subtitle_path, ".tmp")
audio_stream = os.fdopen(audio_descriptor, "wb")
audio_descriptor = -1
with audio_stream:
async for chunk in communicate.stream():
if chunk["type"] == "audio":
audio_stream.write(chunk["data"])
received_audio = True
elif chunk["type"] == "WordBoundary":
boundaries.append(chunk)
audio_stream.flush()
os.fsync(audio_stream.fileno())
if not received_audio:
raise RuntimeError("Edge TTS returned no audio data")
if not boundaries:
raise RuntimeError("Edge TTS returned no word-boundary timing")
subtitle_text = format_word_timed_srt(text, boundaries, max_chars)
subtitle_stream = os.fdopen(
subtitle_descriptor,
"w",
encoding="utf-8",
newline="\n",
)
subtitle_descriptor = -1
with subtitle_stream:
subtitle_stream.write(subtitle_text)
subtitle_stream.flush()
os.fsync(subtitle_stream.fileno())
assert staged_audio is not None
assert staged_subtitle is not None
publish_staged_pair(
staged_audio,
output_path,
staged_subtitle,
subtitle_path,
)
finally:
if audio_descriptor >= 0:
os.close(audio_descriptor)
if subtitle_descriptor >= 0:
os.close(subtitle_descriptor)
if staged_audio is not None:
staged_audio.unlink(missing_ok=True)
if staged_subtitle is not None:
staged_subtitle.unlink(missing_ok=True)
def print_common_voices() -> None:
print("Common edge-tts voices:")
print("Locale Voice Notes")
print("------ ---------------------------- ----------------")
for locale, voice, notes in COMMON_VOICES:
print(f"{locale:<8} {voice:<29} {notes}")
async def print_voices(locale: str | None = None) -> None:
try:
import edge_tts
except ImportError as exc:
raise RuntimeError(
"Missing dependency `edge-tts`. Install it with: "
"python3 -m pip install edge-tts"
) from exc
manager = await edge_tts.VoicesManager.create()
voices = manager.voices
if locale:
voices = [voice for voice in voices if voice.get("Locale") == locale]
for voice in sorted(voices, key=lambda item: (item.get("Locale", ""), item.get("ShortName", ""))):
short_name = voice.get("ShortName", "")
voice_locale = voice.get("Locale", "")
gender = voice.get("Gender", "")
friendly = voice.get("FriendlyName", "")
print(f"{voice_locale:<8} {short_name:<34} {gender:<8} {friendly}")