Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user