| 1 | """MiniMax T2A backend for narration audio generation.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import binascii |
| 6 | import os |
| 7 | from pathlib import Path |
| 8 | |
| 9 | from tts_backends.backend_common import ( |
| 10 | extension_from_format, |
| 11 | post_json, |
| 12 | publish_audio_bytes, |
| 13 | read_api_key, |
| 14 | ) |
| 15 | |
| 16 | |
| 17 | DEFAULT_ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2" |
| 18 | DEFAULT_MODEL = "speech-2.8-hd" |
| 19 | |
| 20 | # International fallback: set MINIMAX_TTS_BASE_URL=https://api.minimax.io if needed. |
| 21 | |
| 22 | |
| 23 | def output_extension(audio_format: str) -> str: |
| 24 | return extension_from_format(audio_format) |
| 25 | |
| 26 | |
| 27 | def read_minimax_api_key(env_name: str) -> str: |
| 28 | return read_api_key(env_name, label="MiniMax") |
| 29 | |
| 30 | |
| 31 | def resolve_url(base_url: str | None = None) -> str: |
| 32 | base = (base_url or os.environ.get("MINIMAX_TTS_BASE_URL") or DEFAULT_ENDPOINT).rstrip("/") |
| 33 | if base.endswith("/t2a_v2"): |
| 34 | return base |
| 35 | if base.endswith("/v1"): |
| 36 | return base + "/t2a_v2" |
| 37 | return base + "/v1/t2a_v2" |
| 38 | |
| 39 | |
| 40 | def generate( |
| 41 | text: str, |
| 42 | output_path: Path, |
| 43 | *, |
| 44 | api_key: str, |
| 45 | voice_id: str, |
| 46 | model: str, |
| 47 | audio_format: str, |
| 48 | sample_rate: int, |
| 49 | bitrate: int, |
| 50 | channel: int, |
| 51 | speed: float, |
| 52 | volume: float, |
| 53 | pitch: int, |
| 54 | language_boost: str, |
| 55 | base_url: str | None, |
| 56 | ) -> None: |
| 57 | payload = { |
| 58 | "model": model, |
| 59 | "text": text, |
| 60 | "stream": False, |
| 61 | "language_boost": language_boost, |
| 62 | "output_format": "hex", |
| 63 | "voice_setting": { |
| 64 | "voice_id": voice_id, |
| 65 | "speed": speed, |
| 66 | "vol": volume, |
| 67 | "pitch": pitch, |
| 68 | }, |
| 69 | "audio_setting": { |
| 70 | "sample_rate": sample_rate, |
| 71 | "bitrate": bitrate, |
| 72 | "format": audio_format, |
| 73 | "channel": channel, |
| 74 | }, |
| 75 | } |
| 76 | data = post_json( |
| 77 | resolve_url(base_url), |
| 78 | headers={"Authorization": f"Bearer {api_key}"}, |
| 79 | payload=payload, |
| 80 | timeout=180, |
| 81 | ) |
| 82 | base_resp = data.get("base_resp") or {} |
| 83 | if base_resp.get("status_code") not in (None, 0, "0"): |
| 84 | raise RuntimeError(f"MiniMax TTS failed: {data}") |
| 85 | |
| 86 | audio_hex = (data.get("data") or {}).get("audio") |
| 87 | if not audio_hex: |
| 88 | raise RuntimeError(f"MiniMax response missing audio data: {data}") |
| 89 | try: |
| 90 | audio = binascii.unhexlify(audio_hex) |
| 91 | except (binascii.Error, ValueError) as exc: |
| 92 | raise RuntimeError("MiniMax response audio is not valid hex data") from exc |
| 93 | publish_audio_bytes(audio, output_path) |
| 94 | |
| 95 | |
| 96 | def print_voices() -> None: |
| 97 | print("MiniMax TTS voices are selected by voice_id.") |
| 98 | print("Use a system voice ID or a cloned voice_id from MiniMax Voice Clone.") |
| 99 | print("Example domestic system voice from MiniMax docs: male-qn-qingse") |
| 100 |