Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Text-to-speech backend helpers for PPT Master narration audio."""
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Shared helpers for TTS backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib import error, request
|
||||
|
||||
|
||||
def read_api_key(*env_names: str, label: str) -> str:
|
||||
for env_name in env_names:
|
||||
api_key = os.environ.get(env_name, "").strip()
|
||||
if api_key:
|
||||
return api_key
|
||||
joined = " or ".join(env_names)
|
||||
raise RuntimeError(f"Missing {label} API key. Set {joined}=<key>.")
|
||||
|
||||
|
||||
def read_http_error(exc: error.HTTPError) -> str:
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
return f"HTTP {exc.code}: {body or exc.reason}"
|
||||
|
||||
|
||||
def post_json(url: str, *, headers: dict[str, str], payload: dict, timeout: int = 120) -> dict:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
**headers,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except error.HTTPError as exc:
|
||||
raise RuntimeError(read_http_error(exc)) from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"HTTP request failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def get_bytes(url: str, *, timeout: int = 180) -> bytes:
|
||||
req = request.Request(url, method="GET")
|
||||
try:
|
||||
with request.urlopen(req, timeout=timeout) as response:
|
||||
return response.read()
|
||||
except error.HTTPError as exc:
|
||||
raise RuntimeError(read_http_error(exc)) from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"Audio download failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def download_audio(url: str, output_path: Path) -> None:
|
||||
publish_audio_bytes(get_bytes(url), output_path)
|
||||
|
||||
|
||||
def temporary_path(target: Path, suffix: str) -> tuple[int, Path]:
|
||||
"""Create a temporary file beside its eventual target."""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, raw_path = tempfile.mkstemp(
|
||||
prefix=f".{target.name}.",
|
||||
suffix=suffix,
|
||||
dir=target.parent,
|
||||
)
|
||||
return descriptor, Path(raw_path)
|
||||
|
||||
|
||||
def publish_staged_pair(
|
||||
staged_first: Path,
|
||||
first_target: Path,
|
||||
staged_second: Path,
|
||||
second_target: Path,
|
||||
) -> None:
|
||||
"""Publish two staged files together, restoring prior targets on failure."""
|
||||
targets = (first_target, second_target)
|
||||
if first_target.resolve() == second_target.resolve():
|
||||
raise ValueError("paired outputs must use different paths")
|
||||
|
||||
backups: dict[Path, Path] = {}
|
||||
published: set[Path] = set()
|
||||
try:
|
||||
for target in targets:
|
||||
if not target.exists():
|
||||
continue
|
||||
descriptor, backup = temporary_path(target, ".bak")
|
||||
os.close(descriptor)
|
||||
backup.unlink()
|
||||
os.replace(target, backup)
|
||||
backups[target] = backup
|
||||
|
||||
for staged, target in (
|
||||
(staged_first, first_target),
|
||||
(staged_second, second_target),
|
||||
):
|
||||
os.replace(staged, target)
|
||||
published.add(target)
|
||||
except Exception:
|
||||
for target in published:
|
||||
target.unlink(missing_ok=True)
|
||||
for target, backup in backups.items():
|
||||
if backup.exists():
|
||||
os.replace(backup, target)
|
||||
raise
|
||||
finally:
|
||||
staged_first.unlink(missing_ok=True)
|
||||
staged_second.unlink(missing_ok=True)
|
||||
for backup in backups.values():
|
||||
backup.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def publish_audio_bytes(audio: bytes, output_path: Path) -> None:
|
||||
"""Publish non-empty provider audio without exposing a partial target."""
|
||||
if not audio:
|
||||
raise RuntimeError("TTS provider returned empty audio data")
|
||||
|
||||
descriptor, staged_path = temporary_path(output_path, ".tmp")
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
descriptor = -1
|
||||
stream.write(audio)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if staged_path.stat().st_size <= 0:
|
||||
raise RuntimeError("TTS provider returned empty audio data")
|
||||
os.replace(staged_path, output_path)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
staged_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def publish_audio_subtitle(
|
||||
audio: bytes,
|
||||
output_path: Path,
|
||||
subtitle: str,
|
||||
subtitle_path: Path,
|
||||
) -> None:
|
||||
"""Publish one audio/SRT pair without exposing mismatched targets."""
|
||||
if not audio:
|
||||
raise RuntimeError("TTS provider returned empty audio data")
|
||||
if not subtitle.strip():
|
||||
raise RuntimeError("TTS provider returned empty subtitle data")
|
||||
|
||||
audio_descriptor = -1
|
||||
subtitle_descriptor = -1
|
||||
staged_audio: Path | None = None
|
||||
staged_subtitle: Path | None = None
|
||||
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:
|
||||
audio_stream.write(audio)
|
||||
audio_stream.flush()
|
||||
os.fsync(audio_stream.fileno())
|
||||
|
||||
subtitle_stream = os.fdopen(
|
||||
subtitle_descriptor,
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
subtitle_descriptor = -1
|
||||
with subtitle_stream:
|
||||
subtitle_stream.write(subtitle)
|
||||
subtitle_stream.flush()
|
||||
os.fsync(subtitle_stream.fileno())
|
||||
|
||||
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 extension_from_format(audio_format: str) -> str:
|
||||
normalized = audio_format.strip().lower()
|
||||
if normalized in {"mp3", "wav"}:
|
||||
return f".{normalized}"
|
||||
raise RuntimeError(
|
||||
f"Unsupported audio format for PPT narration: {audio_format}. "
|
||||
"Use mp3 or wav, or transcode provider output before embedding."
|
||||
)
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Alibaba CosyVoice backend for narration audio generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib import error, request
|
||||
|
||||
from tts_backends.backend_common import (
|
||||
download_audio,
|
||||
extension_from_format,
|
||||
get_bytes,
|
||||
post_json,
|
||||
publish_audio_subtitle,
|
||||
read_api_key,
|
||||
read_http_error,
|
||||
)
|
||||
from tts_backends.backend_edge import (
|
||||
DEFAULT_SUBTITLE_MAX_CHARS,
|
||||
format_word_timed_srt,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer"
|
||||
DEFAULT_MODEL = "cosyvoice-v3-flash"
|
||||
_TICKS_PER_MILLISECOND = 10_000
|
||||
|
||||
|
||||
def output_extension(audio_format: str) -> str:
|
||||
return extension_from_format(audio_format)
|
||||
|
||||
|
||||
def read_cosyvoice_api_key(env_name: str) -> str:
|
||||
env_names = tuple(dict.fromkeys([env_name, "COSYVOICE_API_KEY", "DASHSCOPE_API_KEY"]))
|
||||
return read_api_key(*env_names, label="CosyVoice/DashScope")
|
||||
|
||||
|
||||
def resolve_url(base_url: str | None = None) -> str:
|
||||
base = (base_url or os.environ.get("COSYVOICE_TTS_BASE_URL") or DEFAULT_ENDPOINT).rstrip("/")
|
||||
if base.endswith("/SpeechSynthesizer"):
|
||||
return base
|
||||
return base + "/api/v1/services/audio/tts/SpeechSynthesizer"
|
||||
|
||||
|
||||
def _parse_sse_event(data_lines: list[str]) -> dict | None:
|
||||
payload = "\n".join(data_lines).strip()
|
||||
if not payload or payload == "[DONE]":
|
||||
return None
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError("CosyVoice streaming response contains invalid JSON") from exc
|
||||
if not isinstance(event, dict):
|
||||
raise RuntimeError("CosyVoice streaming event is not an object")
|
||||
return event
|
||||
|
||||
|
||||
def _parse_sse(raw: bytes) -> list[dict]:
|
||||
try:
|
||||
body = raw.decode("utf-8-sig")
|
||||
except UnicodeError as exc:
|
||||
raise RuntimeError("CosyVoice streaming response is not valid UTF-8") from exc
|
||||
|
||||
if not any(line.startswith("data:") for line in body.splitlines()):
|
||||
event = _parse_sse_event([body])
|
||||
return [event] if event is not None else []
|
||||
|
||||
events: list[dict] = []
|
||||
data_lines: list[str] = []
|
||||
for line in body.splitlines():
|
||||
if not line:
|
||||
event = _parse_sse_event(data_lines)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
data_lines = []
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
event = _parse_sse_event(data_lines)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
if not events:
|
||||
raise RuntimeError("CosyVoice streaming response contains no events")
|
||||
return events
|
||||
|
||||
|
||||
def _post_streaming(
|
||||
url: str,
|
||||
*,
|
||||
api_key: str,
|
||||
payload: dict,
|
||||
) -> list[dict]:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-SSE": "enable",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=180) as response:
|
||||
return _parse_sse(response.read())
|
||||
except error.HTTPError as exc:
|
||||
raise RuntimeError(read_http_error(exc)) from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"CosyVoice streaming request failed: {exc.reason}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _ticks(value: object, *, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise RuntimeError(f"CosyVoice word {field} is not numeric")
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError(f"CosyVoice word {field} is not numeric") from exc
|
||||
if not math.isfinite(numeric):
|
||||
raise RuntimeError(f"CosyVoice word {field} is not finite")
|
||||
return int(round(numeric * _TICKS_PER_MILLISECOND))
|
||||
|
||||
|
||||
def _word_boundaries(events: list[dict]) -> tuple[list[dict], str]:
|
||||
sentence_text: dict[int, str] = {}
|
||||
sentence_words: dict[int, list] = {}
|
||||
audio_url = ""
|
||||
|
||||
for event in events:
|
||||
code = event.get("code")
|
||||
message = event.get("message")
|
||||
if code or message:
|
||||
details = ": ".join(str(value) for value in (code, message) if value)
|
||||
raise RuntimeError(f"CosyVoice TTS failed: {details}")
|
||||
output = event.get("output") or {}
|
||||
if not isinstance(output, dict):
|
||||
continue
|
||||
audio = output.get("audio") or {}
|
||||
if isinstance(audio, dict) and isinstance(audio.get("url"), str):
|
||||
audio_url = audio["url"]
|
||||
|
||||
sentence = output.get("sentence") or {}
|
||||
if not isinstance(sentence, dict):
|
||||
continue
|
||||
index = sentence.get("index")
|
||||
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
||||
continue
|
||||
original_text = output.get("original_text")
|
||||
if isinstance(original_text, str) and original_text:
|
||||
sentence_text[index] = original_text
|
||||
words = sentence.get("words")
|
||||
if isinstance(words, list) and words:
|
||||
sentence_words[index] = words
|
||||
|
||||
if not audio_url:
|
||||
raise RuntimeError("CosyVoice streaming response contains no audio URL")
|
||||
if not sentence_words:
|
||||
raise RuntimeError(
|
||||
"CosyVoice returned no word timestamps. Use a cloned voice from "
|
||||
"cosyvoice-v3.5-plus/flash, cosyvoice-v3-plus/flash, or cosyvoice-v2, "
|
||||
"or a system voice marked as timestamp-supported; otherwise pass "
|
||||
"--cosyvoice-audio-only."
|
||||
)
|
||||
|
||||
boundaries: list[dict] = []
|
||||
for sentence_index in sorted(sentence_words):
|
||||
original_text = sentence_text.get(sentence_index, "")
|
||||
for item in sentence_words[sentence_index]:
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError("CosyVoice word timing is not an object")
|
||||
word = item.get("text")
|
||||
begin_index = item.get("begin_index")
|
||||
end_index = item.get("end_index")
|
||||
if (
|
||||
original_text
|
||||
and isinstance(begin_index, int)
|
||||
and not isinstance(begin_index, bool)
|
||||
and isinstance(end_index, int)
|
||||
and not isinstance(end_index, bool)
|
||||
and 0 <= begin_index < end_index <= len(original_text)
|
||||
):
|
||||
word = original_text[begin_index:end_index]
|
||||
if not isinstance(word, str) or not word:
|
||||
raise RuntimeError("CosyVoice word timing contains no text")
|
||||
start = _ticks(item.get("begin_time"), field="start time")
|
||||
end = _ticks(item.get("end_time"), field="end time")
|
||||
if start < 0 or end <= start:
|
||||
raise RuntimeError(
|
||||
"CosyVoice response contains an invalid word interval"
|
||||
)
|
||||
if boundaries and start < boundaries[-1]["offset"]:
|
||||
raise RuntimeError(
|
||||
"CosyVoice word timestamps are not in chronological order"
|
||||
)
|
||||
boundaries.append({
|
||||
"text": word,
|
||||
"offset": start,
|
||||
"duration": end - start,
|
||||
})
|
||||
return boundaries, audio_url
|
||||
|
||||
|
||||
def generate(
|
||||
text: str,
|
||||
output_path: Path,
|
||||
*,
|
||||
api_key: str,
|
||||
voice_id: str,
|
||||
model: str,
|
||||
audio_format: str,
|
||||
sample_rate: int,
|
||||
volume: int | None,
|
||||
rate: float | None,
|
||||
pitch: float | None,
|
||||
instruction: str | None,
|
||||
language_hint: str | None,
|
||||
base_url: str | None,
|
||||
subtitle_path: Path | None = None,
|
||||
subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
|
||||
) -> None:
|
||||
input_payload: dict[str, object] = {
|
||||
"text": text,
|
||||
"voice": voice_id,
|
||||
"format": audio_format,
|
||||
"sample_rate": sample_rate,
|
||||
}
|
||||
if volume is not None:
|
||||
input_payload["volume"] = volume
|
||||
if rate is not None:
|
||||
input_payload["rate"] = rate
|
||||
if pitch is not None:
|
||||
input_payload["pitch"] = pitch
|
||||
if instruction:
|
||||
input_payload["instruction"] = instruction
|
||||
if language_hint:
|
||||
input_payload["language_hints"] = [language_hint]
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": input_payload,
|
||||
}
|
||||
url = resolve_url(base_url)
|
||||
|
||||
if subtitle_path is not None:
|
||||
input_payload["word_timestamp_enabled"] = True
|
||||
events = _post_streaming(
|
||||
url,
|
||||
api_key=api_key,
|
||||
payload=payload,
|
||||
)
|
||||
boundaries, audio_url = _word_boundaries(events)
|
||||
subtitle = format_word_timed_srt(
|
||||
text,
|
||||
boundaries,
|
||||
subtitle_max_chars,
|
||||
provider_label="CosyVoice",
|
||||
)
|
||||
publish_audio_subtitle(
|
||||
get_bytes(audio_url),
|
||||
output_path,
|
||||
subtitle,
|
||||
subtitle_path,
|
||||
)
|
||||
return
|
||||
|
||||
data = post_json(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
payload=payload,
|
||||
timeout=180,
|
||||
)
|
||||
audio = (data.get("output") or {}).get("audio") or {}
|
||||
audio_url = audio.get("url")
|
||||
if not audio_url:
|
||||
raise RuntimeError(f"CosyVoice response missing audio URL: {data}")
|
||||
download_audio(audio_url, output_path)
|
||||
|
||||
|
||||
def print_voices() -> None:
|
||||
print("CosyVoice voices are selected by voice.")
|
||||
print("Use a system voice name or a cloned/designed voice_id from CosyVoice.")
|
||||
print("Timestamp-supported system voice example: longanyang")
|
||||
print(
|
||||
"For SRT, use a supported CosyVoice cloned voice or a system voice "
|
||||
"marked timestamp-supported in the provider catalog."
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,269 @@
|
||||
"""ElevenLabs backend for narration audio generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from urllib import error, request
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from tts_backends.backend_common import (
|
||||
publish_audio_bytes,
|
||||
publish_audio_subtitle,
|
||||
read_api_key,
|
||||
)
|
||||
from tts_backends.backend_edge import (
|
||||
DEFAULT_SUBTITLE_MAX_CHARS,
|
||||
format_word_timed_srt,
|
||||
)
|
||||
|
||||
|
||||
API_BASE = "https://api.elevenlabs.io/v1"
|
||||
_TICKS_PER_SECOND = 10_000_000
|
||||
|
||||
|
||||
def read_elevenlabs_api_key(env_name: str) -> str:
|
||||
return read_api_key(env_name, label="ElevenLabs")
|
||||
|
||||
|
||||
def output_extension(output_format: str) -> str:
|
||||
codec = output_format.split("_", 1)[0].lower()
|
||||
if codec in {"mp3", "wav"}:
|
||||
return f".{codec}"
|
||||
raise RuntimeError(
|
||||
f"Unsupported ElevenLabs output format for PPT narration: {output_format}. "
|
||||
"Use an mp3_* or wav_* format."
|
||||
)
|
||||
|
||||
|
||||
def _read_http_error(exc: error.HTTPError) -> str:
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
return f"HTTP {exc.code}: {body or exc.reason}"
|
||||
|
||||
|
||||
def _seconds_to_ticks(value: object, *, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise RuntimeError(f"ElevenLabs alignment {field} is not numeric")
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError(
|
||||
f"ElevenLabs alignment {field} is not numeric"
|
||||
) from exc
|
||||
if not math.isfinite(numeric):
|
||||
raise RuntimeError(f"ElevenLabs alignment {field} is not finite")
|
||||
return int(round(numeric * _TICKS_PER_SECOND))
|
||||
|
||||
|
||||
def _is_unspaced_character(character: str) -> bool:
|
||||
"""Return whether a script is safer to time one character at a time."""
|
||||
codepoint = ord(character)
|
||||
return (
|
||||
0x3040 <= codepoint <= 0x30FF
|
||||
or 0x3400 <= codepoint <= 0x4DBF
|
||||
or 0x4E00 <= codepoint <= 0x9FFF
|
||||
or 0xAC00 <= codepoint <= 0xD7AF
|
||||
or 0xF900 <= codepoint <= 0xFAFF
|
||||
or 0x20000 <= codepoint <= 0x2FA1F
|
||||
)
|
||||
|
||||
|
||||
def _alignment_characters(
|
||||
text: str,
|
||||
alignment: object,
|
||||
) -> tuple[list[str], list[object], list[object]]:
|
||||
if not isinstance(alignment, dict):
|
||||
raise RuntimeError("ElevenLabs response contains no original-text alignment")
|
||||
raw_characters = alignment.get("characters")
|
||||
raw_starts = alignment.get("character_start_times_seconds")
|
||||
raw_ends = alignment.get("character_end_times_seconds")
|
||||
if not all(isinstance(values, list) for values in (
|
||||
raw_characters,
|
||||
raw_starts,
|
||||
raw_ends,
|
||||
)):
|
||||
raise RuntimeError("ElevenLabs alignment arrays are missing")
|
||||
if not raw_characters or not (
|
||||
len(raw_characters) == len(raw_starts) == len(raw_ends)
|
||||
):
|
||||
raise RuntimeError("ElevenLabs alignment arrays have inconsistent lengths")
|
||||
if any(not isinstance(item, str) or not item for item in raw_characters):
|
||||
raise RuntimeError("ElevenLabs alignment contains an invalid character")
|
||||
if "".join(raw_characters) != text:
|
||||
raise RuntimeError(
|
||||
"ElevenLabs original-text alignment differs from the narration text; "
|
||||
"audio and subtitles were not published"
|
||||
)
|
||||
|
||||
characters: list[str] = []
|
||||
starts: list[object] = []
|
||||
ends: list[object] = []
|
||||
for raw_character, start, end in zip(
|
||||
raw_characters,
|
||||
raw_starts,
|
||||
raw_ends,
|
||||
):
|
||||
for character in raw_character:
|
||||
characters.append(character)
|
||||
starts.append(start)
|
||||
ends.append(end)
|
||||
return characters, starts, ends
|
||||
|
||||
|
||||
def _word_boundaries(text: str, alignment: object) -> list[dict]:
|
||||
characters, starts, ends = _alignment_characters(text, alignment)
|
||||
boundaries: list[dict] = []
|
||||
index = 0
|
||||
while index < len(characters):
|
||||
character = characters[index]
|
||||
if not character.isalnum():
|
||||
index += 1
|
||||
continue
|
||||
|
||||
token_end = index + 1
|
||||
if not _is_unspaced_character(character):
|
||||
while token_end < len(characters):
|
||||
following = characters[token_end]
|
||||
if (
|
||||
not following.isalnum()
|
||||
or _is_unspaced_character(following)
|
||||
):
|
||||
break
|
||||
token_end += 1
|
||||
|
||||
start = _seconds_to_ticks(starts[index], field="start time")
|
||||
end = _seconds_to_ticks(ends[token_end - 1], field="end time")
|
||||
if start < 0 or end <= start:
|
||||
raise RuntimeError(
|
||||
"ElevenLabs alignment contains an invalid character interval"
|
||||
)
|
||||
if boundaries and start < boundaries[-1]["offset"]:
|
||||
raise RuntimeError(
|
||||
"ElevenLabs alignment is not in chronological order"
|
||||
)
|
||||
boundaries.append({
|
||||
"text": "".join(characters[index:token_end]),
|
||||
"offset": start,
|
||||
"duration": end - start,
|
||||
})
|
||||
index = token_end
|
||||
|
||||
if not boundaries:
|
||||
raise RuntimeError("ElevenLabs alignment contains no timed words")
|
||||
return boundaries
|
||||
|
||||
|
||||
def generate(
|
||||
text: str,
|
||||
output_path: Path,
|
||||
*,
|
||||
api_key: str,
|
||||
voice_id: str,
|
||||
model: str,
|
||||
output_format: str,
|
||||
stability: float | None,
|
||||
similarity_boost: float | None,
|
||||
style: float | None,
|
||||
speed: float | None,
|
||||
speaker_boost: bool | None,
|
||||
subtitle_path: Path | None = None,
|
||||
subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
|
||||
) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"text": text,
|
||||
"model_id": model,
|
||||
}
|
||||
|
||||
voice_settings: dict[str, object] = {}
|
||||
if stability is not None:
|
||||
voice_settings["stability"] = stability
|
||||
if similarity_boost is not None:
|
||||
voice_settings["similarity_boost"] = similarity_boost
|
||||
if style is not None:
|
||||
voice_settings["style"] = style
|
||||
if speed is not None:
|
||||
voice_settings["speed"] = speed
|
||||
if speaker_boost is not None:
|
||||
voice_settings["use_speaker_boost"] = speaker_boost
|
||||
if voice_settings:
|
||||
payload["voice_settings"] = voice_settings
|
||||
|
||||
query = urlencode({"output_format": output_format})
|
||||
suffix = "/with-timestamps" if subtitle_path is not None else ""
|
||||
url = (
|
||||
f"{API_BASE}/text-to-speech/{quote(voice_id, safe='')}{suffix}?{query}"
|
||||
)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"xi-api-key": api_key,
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=120) as response:
|
||||
response_data = response.read()
|
||||
except error.HTTPError as exc:
|
||||
raise RuntimeError(_read_http_error(exc)) from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"ElevenLabs request failed: {exc.reason}") from exc
|
||||
|
||||
if subtitle_path is None:
|
||||
publish_audio_bytes(response_data, output_path)
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(response_data.decode("utf-8"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(
|
||||
"ElevenLabs timing response is not valid UTF-8 JSON"
|
||||
) from exc
|
||||
encoded_audio = data.get("audio_base64")
|
||||
if not isinstance(encoded_audio, str) or not encoded_audio:
|
||||
raise RuntimeError("ElevenLabs timing response contains no audio")
|
||||
try:
|
||||
audio = base64.b64decode(encoded_audio, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise RuntimeError(
|
||||
"ElevenLabs timing response audio is not valid base64"
|
||||
) from exc
|
||||
subtitle = format_word_timed_srt(
|
||||
text,
|
||||
_word_boundaries(text, data.get("alignment")),
|
||||
subtitle_max_chars,
|
||||
provider_label="ElevenLabs",
|
||||
)
|
||||
publish_audio_subtitle(audio, output_path, subtitle, subtitle_path)
|
||||
|
||||
|
||||
def print_voices(api_key: str) -> None:
|
||||
req = request.Request(
|
||||
f"{API_BASE}/voices",
|
||||
headers={"xi-api-key": api_key},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=60) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
except error.HTTPError as exc:
|
||||
raise RuntimeError(_read_http_error(exc)) from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"ElevenLabs request failed: {exc.reason}") from exc
|
||||
|
||||
print("ElevenLabs voices:")
|
||||
print("Voice ID Name Category")
|
||||
print("----------------------- ----------------------------- ----------")
|
||||
for item in data.get("voices", []):
|
||||
voice_id = item.get("voice_id", "")
|
||||
name = item.get("name", "")
|
||||
category = item.get("category", "")
|
||||
print(f"{voice_id:<23} {name:<29} {category}")
|
||||
@@ -0,0 +1,195 @@
|
||||
"""MiniMax T2A backend for narration audio generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from tts_backends.backend_common import (
|
||||
extension_from_format,
|
||||
get_bytes,
|
||||
post_json,
|
||||
publish_audio_bytes,
|
||||
publish_audio_subtitle,
|
||||
read_api_key,
|
||||
)
|
||||
from tts_backends.backend_edge import (
|
||||
DEFAULT_SUBTITLE_MAX_CHARS,
|
||||
format_word_timed_srt,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2"
|
||||
DEFAULT_MODEL = "speech-2.8-hd"
|
||||
_TICKS_PER_MILLISECOND = 10_000
|
||||
|
||||
# International fallback: set MINIMAX_TTS_BASE_URL=https://api.minimax.io if needed.
|
||||
|
||||
|
||||
def output_extension(audio_format: str) -> str:
|
||||
return extension_from_format(audio_format)
|
||||
|
||||
|
||||
def read_minimax_api_key(env_name: str) -> str:
|
||||
return read_api_key(env_name, label="MiniMax")
|
||||
|
||||
|
||||
def resolve_url(base_url: str | None = None) -> str:
|
||||
base = (base_url or os.environ.get("MINIMAX_TTS_BASE_URL") or DEFAULT_ENDPOINT).rstrip("/")
|
||||
if base.endswith("/t2a_v2"):
|
||||
return base
|
||||
if base.endswith("/v1"):
|
||||
return base + "/t2a_v2"
|
||||
return base + "/v1/t2a_v2"
|
||||
|
||||
|
||||
def _ticks(value: object, *, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise RuntimeError(f"MiniMax subtitle {field} is not numeric")
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError(f"MiniMax subtitle {field} is not numeric") from exc
|
||||
if not math.isfinite(numeric):
|
||||
raise RuntimeError(f"MiniMax subtitle {field} is not finite")
|
||||
return int(round(numeric * _TICKS_PER_MILLISECOND))
|
||||
|
||||
|
||||
def _word_boundaries(raw: object) -> list[dict]:
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise RuntimeError("MiniMax subtitle response contains no sentence blocks")
|
||||
|
||||
boundaries: list[dict] = []
|
||||
for sentence in raw:
|
||||
if not isinstance(sentence, dict):
|
||||
raise RuntimeError("MiniMax subtitle sentence block is not an object")
|
||||
words = sentence.get("timestamped_words")
|
||||
if not isinstance(words, list) or not words:
|
||||
raise RuntimeError("MiniMax subtitle sentence block contains no word timings")
|
||||
for item in words:
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError("MiniMax subtitle word timing is not an object")
|
||||
word = item.get("word")
|
||||
if not isinstance(word, str) or not word:
|
||||
raise RuntimeError("MiniMax subtitle word timing contains no text")
|
||||
start = _ticks(item.get("time_begin"), field="start time")
|
||||
end = _ticks(item.get("time_end"), field="end time")
|
||||
if start < 0 or end <= start:
|
||||
raise RuntimeError(
|
||||
"MiniMax subtitle response contains an invalid word interval"
|
||||
)
|
||||
if boundaries and start < boundaries[-1]["offset"]:
|
||||
raise RuntimeError("MiniMax subtitle words are not in chronological order")
|
||||
boundaries.append(
|
||||
{
|
||||
"text": word,
|
||||
"offset": start,
|
||||
"duration": end - start,
|
||||
}
|
||||
)
|
||||
return boundaries
|
||||
|
||||
|
||||
def _download_subtitle(
|
||||
url: str,
|
||||
text: str,
|
||||
max_chars: int,
|
||||
) -> str:
|
||||
try:
|
||||
raw = json.loads(get_bytes(url).decode("utf-8-sig"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("MiniMax subtitle response is not valid UTF-8 JSON") from exc
|
||||
return format_word_timed_srt(
|
||||
text,
|
||||
_word_boundaries(raw),
|
||||
max_chars,
|
||||
provider_label="MiniMax",
|
||||
)
|
||||
|
||||
|
||||
def generate(
|
||||
text: str,
|
||||
output_path: Path,
|
||||
*,
|
||||
api_key: str,
|
||||
voice_id: str,
|
||||
model: str,
|
||||
audio_format: str,
|
||||
sample_rate: int,
|
||||
bitrate: int,
|
||||
channel: int,
|
||||
speed: float,
|
||||
volume: float,
|
||||
pitch: int,
|
||||
language_boost: str,
|
||||
base_url: str | None,
|
||||
subtitle_path: Path | None = None,
|
||||
subtitle_max_chars: int = DEFAULT_SUBTITLE_MAX_CHARS,
|
||||
) -> None:
|
||||
payload = {
|
||||
"model": model,
|
||||
"text": text,
|
||||
"stream": False,
|
||||
"language_boost": language_boost,
|
||||
"output_format": "hex",
|
||||
"voice_setting": {
|
||||
"voice_id": voice_id,
|
||||
"speed": speed,
|
||||
"vol": volume,
|
||||
"pitch": pitch,
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": sample_rate,
|
||||
"bitrate": bitrate,
|
||||
"format": audio_format,
|
||||
"channel": channel,
|
||||
},
|
||||
}
|
||||
if subtitle_path is not None:
|
||||
payload["subtitle_enable"] = True
|
||||
payload["subtitle_type"] = "word"
|
||||
|
||||
data = post_json(
|
||||
resolve_url(base_url),
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
payload=payload,
|
||||
timeout=180,
|
||||
)
|
||||
base_resp = data.get("base_resp") or {}
|
||||
if base_resp.get("status_code") not in (None, 0, "0"):
|
||||
raise RuntimeError(f"MiniMax TTS failed: {data}")
|
||||
|
||||
response_data = data.get("data") or {}
|
||||
audio_hex = response_data.get("audio")
|
||||
if not audio_hex:
|
||||
raise RuntimeError(f"MiniMax response missing audio data: {data}")
|
||||
try:
|
||||
audio = binascii.unhexlify(audio_hex)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise RuntimeError("MiniMax response audio is not valid hex data") from exc
|
||||
|
||||
if subtitle_path is None:
|
||||
publish_audio_bytes(audio, output_path)
|
||||
return
|
||||
|
||||
subtitle_url = response_data.get("subtitle_file")
|
||||
if not isinstance(subtitle_url, str) or not subtitle_url.strip():
|
||||
trace_id = data.get("trace_id") or "unknown"
|
||||
raise RuntimeError(
|
||||
f"MiniMax response missing subtitle file (trace_id={trace_id})"
|
||||
)
|
||||
subtitle = _download_subtitle(
|
||||
subtitle_url,
|
||||
text,
|
||||
subtitle_max_chars,
|
||||
)
|
||||
publish_audio_subtitle(audio, output_path, subtitle, subtitle_path)
|
||||
|
||||
|
||||
def print_voices() -> None:
|
||||
print("MiniMax TTS voices are selected by voice_id.")
|
||||
print("Use a system voice ID or a cloned voice_id from MiniMax Voice Clone.")
|
||||
print("Example domestic system voice from MiniMax docs: male-qn-qingse")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Alibaba Qwen TTS backend for narration audio generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from tts_backends.backend_common import download_audio, post_json, read_api_key
|
||||
|
||||
|
||||
DEFAULT_ENDPOINT = "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
DEFAULT_MODEL = "qwen3-tts-flash"
|
||||
|
||||
|
||||
def output_extension() -> str:
|
||||
return ".wav"
|
||||
|
||||
|
||||
def read_qwen_api_key(env_name: str | None = None) -> str:
|
||||
if env_name:
|
||||
return read_api_key(env_name, label="Qwen/DashScope")
|
||||
return read_api_key("QWEN_API_KEY", "DASHSCOPE_API_KEY", label="Qwen/DashScope")
|
||||
|
||||
|
||||
def resolve_url(base_url: str | None = None) -> str:
|
||||
base = (base_url or os.environ.get("QWEN_TTS_BASE_URL") or DEFAULT_ENDPOINT).rstrip("/")
|
||||
if base.endswith("/generation"):
|
||||
return base
|
||||
return base + "/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
|
||||
def generate(
|
||||
text: str,
|
||||
output_path: Path,
|
||||
*,
|
||||
api_key: str,
|
||||
voice_id: str,
|
||||
model: str,
|
||||
language_type: str,
|
||||
instructions: str | None,
|
||||
optimize_instructions: bool | None,
|
||||
base_url: str | None,
|
||||
) -> None:
|
||||
input_payload: dict[str, object] = {
|
||||
"text": text,
|
||||
"voice": voice_id,
|
||||
}
|
||||
if language_type:
|
||||
input_payload["language_type"] = language_type
|
||||
if instructions:
|
||||
input_payload["instructions"] = instructions
|
||||
if optimize_instructions is not None:
|
||||
input_payload["optimize_instructions"] = optimize_instructions
|
||||
|
||||
data = post_json(
|
||||
resolve_url(base_url),
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
payload={
|
||||
"model": model,
|
||||
"input": input_payload,
|
||||
},
|
||||
timeout=180,
|
||||
)
|
||||
audio = (data.get("output") or {}).get("audio") or {}
|
||||
audio_url = audio.get("url")
|
||||
if not audio_url:
|
||||
raise RuntimeError(f"Qwen TTS response missing audio URL: {data}")
|
||||
download_audio(audio_url, output_path)
|
||||
|
||||
|
||||
def print_voices() -> None:
|
||||
print("Qwen TTS voices are selected by voice.")
|
||||
print("Use a system voice name or a cloned voice from Qwen voice cloning.")
|
||||
print("Example system voice from Alibaba Cloud docs: Cherry")
|
||||
print("Current Qwen TTS responses do not include word/character timestamps.")
|
||||
Reference in New Issue
Block a user