726 lines
27 KiB
Python
726 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import weakref
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Literal
|
|
|
|
import aiohttp
|
|
|
|
from livekit.agents import (
|
|
DEFAULT_API_CONNECT_OPTIONS,
|
|
APIConnectionError,
|
|
APIConnectOptions,
|
|
APIError,
|
|
APIStatusError,
|
|
APITimeoutError,
|
|
tokenize,
|
|
tts,
|
|
utils,
|
|
)
|
|
from livekit.agents.types import NOT_GIVEN, NotGivenOr
|
|
|
|
from .log import logger
|
|
|
|
TTSModel = Literal[
|
|
"speech-2.8-hd",
|
|
"speech-2.8-turbo",
|
|
"speech-2.6-hd",
|
|
"speech-2.6-turbo",
|
|
"speech-2.5-hd-preview",
|
|
"speech-2.5-turbo-preview",
|
|
"speech-02-hd",
|
|
"speech-02-turbo",
|
|
"speech-01-hd",
|
|
"speech-01-turbo",
|
|
]
|
|
|
|
# Minimax TTS Voice IDs
|
|
# Defines commonly used voices for static analysis.
|
|
# See full voice list in Minimax documentation
|
|
TTSVoice = Literal[
|
|
# Social Media Voices
|
|
"socialmedia_female_2_v1",
|
|
"socialmedia_female_1_v1",
|
|
# Voice Agent Series
|
|
"voice_agent_Female_Phone_4",
|
|
"voice_agent_Male_Phone_1",
|
|
"voice_agent_Male_Phone_2",
|
|
# English Voices - Female
|
|
"English_StressedLady",
|
|
"English_SentimentalLady",
|
|
"English_radiant_girl",
|
|
# English Voices - Male
|
|
"English_WiseScholar",
|
|
"English_Persuasive_Man",
|
|
"English_Explanatory_Man",
|
|
"English_Insightful_Speaker",
|
|
# Japanese Voices
|
|
"japanese_male_social_media_1_v2",
|
|
"japanese_female_social_media_1_v2",
|
|
# French Voices
|
|
"French_CasualMan",
|
|
"French_Female Journalist",
|
|
# Spanish Voices
|
|
"Spanish_Narrator",
|
|
"Spanish_WiseScholar",
|
|
"Spanish_ThoughtfulMan",
|
|
# Arabic Voices
|
|
"Arabic_CalmWoman",
|
|
"Arabic_FriendlyGuy",
|
|
# Portuguese Voices
|
|
"Portuguese_ThoughtfulLady",
|
|
# German Voices
|
|
"German_PlayfulMan",
|
|
"German_SweetLady",
|
|
# MOSS Audio Series
|
|
"moss_audio_7c7e7ae2-7356-11f0-9540-7ef9b4b62566",
|
|
"moss_audio_b118f320-78c0-11f0-bbeb-26e8167c4779",
|
|
"moss_audio_84f32de9-2363-11f0-b7ab-d255fae1f27b",
|
|
"moss_audio_82ebf67c-78c8-11f0-8e8e-36b92fbb4f95",
|
|
]
|
|
|
|
DEFAULT_MODEL = "speech-02-turbo"
|
|
DEFAULT_VOICE_ID = "socialmedia_female_2_v1"
|
|
|
|
|
|
# Note: "fluent" emotion is only supported by speech-2.6-* models
|
|
TTSEmotion = Literal[
|
|
"happy", "sad", "angry", "fearful", "disgusted", "surprised", "neutral", "fluent"
|
|
]
|
|
|
|
TTSLanguageBoost = Literal[
|
|
"auto",
|
|
"Chinese",
|
|
"Chinese,Yue",
|
|
"English",
|
|
"Arabic",
|
|
"Russian",
|
|
"Spanish",
|
|
"French",
|
|
"Portuguese",
|
|
"German",
|
|
"Turkish",
|
|
"Dutch",
|
|
"Ukrainian",
|
|
"Vietnamese",
|
|
"Indonesian",
|
|
"Japanese",
|
|
"Italian",
|
|
"Korean",
|
|
"Thai",
|
|
"Polish",
|
|
"Romanian",
|
|
"Greek",
|
|
"Czech",
|
|
"Finnish",
|
|
"Hindi",
|
|
"Bulgarian",
|
|
"Danish",
|
|
"Hebrew",
|
|
"Malay",
|
|
"Persian",
|
|
"Slovak",
|
|
"Swedish",
|
|
"Croatian",
|
|
"Filipino",
|
|
"Hungarian",
|
|
"Norwegian",
|
|
"Slovenian",
|
|
"Catalan",
|
|
"Nynorsk",
|
|
"Tamil",
|
|
"Afrikaans",
|
|
]
|
|
|
|
TTSAudioFormat = Literal["pcm", "mp3", "flac", "wav"]
|
|
TTSSampleRate = Literal[8000, 16000, 22050, 24000, 32000, 44100]
|
|
TTSBitRate = Literal[32000, 64000, 128000, 256000] # only for mp3 format
|
|
|
|
DEFAULT_BASE_URL = (
|
|
"https://api-uw.minimax.io" # or "https://api.minimaxi.chat or https://api.minimax.io"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _TTSOptions:
|
|
api_key: str
|
|
base_url: str
|
|
model: TTSModel | str
|
|
voice_id: TTSVoice | str
|
|
sample_rate: TTSSampleRate
|
|
bitrate: TTSBitRate
|
|
emotion: TTSEmotion | None
|
|
speed: float # [0.5, 2.0]
|
|
vol: float # (0, 10]
|
|
pitch: int # [-12, 12]
|
|
text_normalization: bool
|
|
pronunciation_dict: dict[str, list[str]] | None
|
|
language_boost: TTSLanguageBoost | None
|
|
# voice_modify
|
|
intensity: int | None
|
|
timbre: int | None
|
|
audio_format: TTSAudioFormat
|
|
|
|
|
|
class TTS(tts.TTS):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
model: TTSModel | str = DEFAULT_MODEL,
|
|
voice: TTSVoice | str = DEFAULT_VOICE_ID,
|
|
emotion: TTSEmotion | None = None,
|
|
speed: float = 1.0,
|
|
vol: float = 1.0,
|
|
pitch: int = 0,
|
|
text_normalization: bool = False,
|
|
audio_format: TTSAudioFormat = "mp3",
|
|
pronunciation_dict: dict[str, list[str]] | None = None,
|
|
intensity: int | None = None,
|
|
timbre: int | None = None,
|
|
language_boost: TTSLanguageBoost | None = None,
|
|
sample_rate: TTSSampleRate = 24000,
|
|
bitrate: TTSBitRate = 128000,
|
|
tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
|
|
text_pacing: tts.SentenceStreamPacer | bool = False,
|
|
api_key: str | None = None,
|
|
base_url: NotGivenOr[str] = NOT_GIVEN,
|
|
http_session: aiohttp.ClientSession | None = None,
|
|
):
|
|
"""Minimax TTS plugin
|
|
|
|
Args:
|
|
model (TTSModel | str, optional): The Minimax TTS model to use. Defaults to DEFAULT_MODEL.
|
|
Available models: speech-2.8-hd, speech-2.8-turbo, speech-2.6-hd, speech-2.6-turbo,
|
|
speech-2.5-hd-preview, speech-2.5-turbo-preview, speech-02-hd, speech-02-turbo,
|
|
speech-01-hd, speech-01-turbo.
|
|
voice (TTSVoice | str, optional): The voice to use. Defaults to DEFAULT_VOICE_ID.
|
|
emotion (TTSEmotion | None, optional): Emotion control for speech synthesis.
|
|
Options: "happy", "sad", "angry", "fearful", "disgusted", "surprised", "neutral", "fluent".
|
|
Note: "fluent" emotion is only supported by speech-2.6-* models. Defaults to None.
|
|
speed (float, optional): Speech speed, higher values speak faster. Range is [0.5, 2.0].
|
|
vol (float, optional): Speech volume, range is [0, 10].
|
|
pitch (int, optional): Speech pitch adjustment, range is [-12, 12].
|
|
text_normalization (bool, optional): Enable text normalization (Chinese/English). Improves performance
|
|
in digit-reading scenarios at the cost of slightly higher latency. Defaults to False.
|
|
audio_format (TTSAudioFormat, optional): The audio format to use. Defaults to "mp3".
|
|
pronunciation_dict (dict[str, list[str]] | None, optional): Defines pronunciation rules for specific characters or symbols.
|
|
intensity (int | None, optional): Corresponds to the "Strong/Softer" slider on the official page. Range [-100, 100].
|
|
language_boost (TTSLanguageBoost | None, optional): Controls whether recognition for specific minority languages and dialects is enhanced. Defaults to None.
|
|
timbre (int | None, optional): Corresponds to the "Nasal/Crisp" slider on the official page. Range: [-100, 100].
|
|
sample_rate (TTSSampleRate, optional): The audio sample rate in Hz. Defaults to 24000.
|
|
bitrate (TTSBitRate, optional): The audio bitrate in kbps. Defaults to 128000.
|
|
tokenizer (NotGivenOr[tokenize.SentenceTokenizer], optional): The sentence tokenizer to use. Defaults to `livekit.agents.tokenize.blingfire.SentenceTokenizer`.
|
|
text_pacing (tts.SentenceStreamPacer | bool, optional): Enable text pacing for sentence-level timing control. Defaults to False.
|
|
api_key (str | None, optional): The Minimax API key. Defaults to None.
|
|
base_url (NotGivenOr[str], optional): The base URL for the Minimax API. Defaults to NOT_GIVEN.
|
|
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
|
|
"""
|
|
super().__init__(
|
|
capabilities=tts.TTSCapabilities(streaming=True, aligned_transcript=False),
|
|
sample_rate=sample_rate,
|
|
num_channels=1,
|
|
)
|
|
|
|
base_url = (
|
|
base_url
|
|
if utils.is_given(base_url)
|
|
else os.environ.get("MINIMAX_BASE_URL", DEFAULT_BASE_URL)
|
|
)
|
|
|
|
minimax_api_key = api_key or os.environ.get("MINIMAX_API_KEY")
|
|
if not minimax_api_key:
|
|
raise ValueError(
|
|
"MiniMax API key is required, either as argument or set"
|
|
" MINIMAX_API_KEY environment variable"
|
|
)
|
|
|
|
if not (0.5 <= speed <= 2.0):
|
|
raise ValueError(f"speed must be between 0.5 and 2.0, but got {speed}")
|
|
if intensity is not None and not (-100 <= intensity <= 100):
|
|
raise ValueError(f"intensity must be between -100 and 100, but got {intensity}")
|
|
if timbre is not None and not (-100 <= timbre <= 100):
|
|
raise ValueError(f"timbre must be between -100 and 100, but got {timbre}")
|
|
|
|
# Validate fluent emotion is only used with speech-2.6-* models
|
|
if emotion == "fluent" and not model.startswith("speech-2.6"):
|
|
raise ValueError(
|
|
f'"fluent" emotion is only supported by speech-2.6-* models, '
|
|
f'but got model "{model}". Please use speech-2.6-hd or speech-2.6-turbo.'
|
|
)
|
|
|
|
self._sentence_tokenizer = (
|
|
tokenizer if utils.is_given(tokenizer) else tokenize.blingfire.SentenceTokenizer()
|
|
)
|
|
|
|
self._stream_pacer: tts.SentenceStreamPacer | None = None
|
|
if text_pacing is True:
|
|
self._stream_pacer = tts.SentenceStreamPacer()
|
|
elif isinstance(text_pacing, tts.SentenceStreamPacer):
|
|
self._stream_pacer = text_pacing
|
|
|
|
self._opts = _TTSOptions(
|
|
model=model,
|
|
voice_id=voice,
|
|
api_key=minimax_api_key,
|
|
base_url=base_url,
|
|
sample_rate=sample_rate,
|
|
emotion=emotion,
|
|
bitrate=bitrate,
|
|
speed=speed,
|
|
pitch=pitch,
|
|
vol=vol,
|
|
text_normalization=text_normalization,
|
|
timbre=timbre,
|
|
language_boost=language_boost,
|
|
pronunciation_dict=pronunciation_dict,
|
|
intensity=intensity,
|
|
audio_format=audio_format,
|
|
)
|
|
|
|
self._session = http_session
|
|
self._streams = weakref.WeakSet[SynthesizeStream]()
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return self._opts.model
|
|
|
|
@property
|
|
def provider(self) -> str:
|
|
return "MiniMax"
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
model: NotGivenOr[TTSModel | str] = NOT_GIVEN,
|
|
voice: NotGivenOr[TTSVoice | str] = NOT_GIVEN,
|
|
emotion: NotGivenOr[TTSEmotion | None] = NOT_GIVEN,
|
|
speed: NotGivenOr[float] = NOT_GIVEN,
|
|
vol: NotGivenOr[float] = NOT_GIVEN,
|
|
pitch: NotGivenOr[int] = NOT_GIVEN,
|
|
text_normalization: NotGivenOr[bool] = NOT_GIVEN,
|
|
audio_format: NotGivenOr[TTSAudioFormat] = NOT_GIVEN,
|
|
pronunciation_dict: NotGivenOr[dict[str, list[str]]] = NOT_GIVEN,
|
|
intensity: NotGivenOr[int] = NOT_GIVEN,
|
|
timbre: NotGivenOr[int] = NOT_GIVEN,
|
|
language_boost: NotGivenOr[TTSLanguageBoost | None] = NOT_GIVEN,
|
|
) -> None:
|
|
"""Update the TTS configuration options."""
|
|
if utils.is_given(model):
|
|
self._opts.model = model
|
|
|
|
if utils.is_given(voice):
|
|
self._opts.voice_id = voice
|
|
|
|
if utils.is_given(emotion):
|
|
self._opts.emotion = emotion
|
|
|
|
if utils.is_given(speed):
|
|
self._opts.speed = speed
|
|
|
|
if utils.is_given(vol):
|
|
self._opts.vol = vol
|
|
|
|
if utils.is_given(pitch):
|
|
self._opts.pitch = pitch
|
|
|
|
if utils.is_given(text_normalization):
|
|
self._opts.text_normalization = text_normalization
|
|
|
|
if utils.is_given(audio_format):
|
|
self._opts.audio_format = audio_format
|
|
|
|
if utils.is_given(pronunciation_dict):
|
|
self._opts.pronunciation_dict = pronunciation_dict
|
|
|
|
if utils.is_given(intensity):
|
|
self._opts.intensity = intensity
|
|
|
|
if utils.is_given(timbre):
|
|
self._opts.timbre = timbre
|
|
|
|
if utils.is_given(language_boost):
|
|
self._opts.language_boost = language_boost
|
|
|
|
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
if not self._session:
|
|
self._session = utils.http_context.http_session()
|
|
|
|
return self._session
|
|
|
|
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
|
|
url = self._opts.base_url
|
|
if url.startswith("http"):
|
|
url = url.replace("http", "ws", 1)
|
|
url = f"{url}/ws/v1/t2a_v2"
|
|
|
|
headers = {"Authorization": f"Bearer {self._opts.api_key}"}
|
|
session = self._ensure_session()
|
|
ws = await asyncio.wait_for(session.ws_connect(url, headers=headers), timeout)
|
|
|
|
# Log WebSocket connection establishment
|
|
logger.debug(f"MiniMax WebSocket connected to {url}")
|
|
|
|
return ws
|
|
|
|
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
await ws.close()
|
|
|
|
def synthesize(
|
|
self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
) -> ChunkedStream:
|
|
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
|
|
|
|
def stream(
|
|
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
) -> SynthesizeStream:
|
|
stream = SynthesizeStream(tts=self, conn_options=conn_options)
|
|
self._streams.add(stream)
|
|
return stream
|
|
|
|
async def aclose(self) -> None:
|
|
for stream in list(self._streams):
|
|
await stream.aclose()
|
|
|
|
self._streams.clear()
|
|
|
|
|
|
class SynthesizeStream(tts.SynthesizeStream):
|
|
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions):
|
|
super().__init__(tts=tts, conn_options=conn_options)
|
|
self._tts: TTS = tts
|
|
self._opts = replace(tts._opts)
|
|
|
|
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
# Initialize with temporary ID, will be updated from WebSocket messages
|
|
request_id = utils.shortuuid()
|
|
trace_id = request_id # Use trace_id directly instead of creating a dict
|
|
|
|
output_emitter.initialize(
|
|
request_id=request_id,
|
|
sample_rate=self._opts.sample_rate,
|
|
num_channels=1,
|
|
mime_type=f"audio/{self._opts.audio_format}",
|
|
stream=True,
|
|
)
|
|
|
|
sentence_stream = self._tts._sentence_tokenizer.stream()
|
|
if self._tts._stream_pacer:
|
|
sentence_stream = self._tts._stream_pacer.wrap(
|
|
sent_stream=sentence_stream,
|
|
audio_emitter=output_emitter,
|
|
)
|
|
|
|
task_started = asyncio.Future[None]()
|
|
|
|
async def _input_task() -> None:
|
|
async for data in self._input_ch:
|
|
if isinstance(data, self._FlushSentinel):
|
|
sentence_stream.flush()
|
|
continue
|
|
|
|
sentence_stream.push_text(data)
|
|
sentence_stream.end_input()
|
|
|
|
async def _sentence_stream_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
start_msg = _to_minimax_options(self._opts)
|
|
start_msg["event"] = "task_start"
|
|
await ws.send_str(json.dumps(start_msg))
|
|
|
|
try:
|
|
await asyncio.wait_for(task_started, self._conn_options.timeout)
|
|
except asyncio.TimeoutError as e:
|
|
raise APITimeoutError("task_start timed out") from e
|
|
|
|
async for sentence in sentence_stream:
|
|
self._mark_started()
|
|
await ws.send_str(json.dumps({"event": "task_continue", "text": sentence.token}))
|
|
|
|
await ws.send_str(json.dumps({"event": "task_finish"}))
|
|
|
|
async def _recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
# Initialize trace_id to ensure it's available in all code paths
|
|
current_trace_id = trace_id
|
|
|
|
while True:
|
|
msg = await ws.receive()
|
|
if msg.type in (
|
|
aiohttp.WSMsgType.CLOSED,
|
|
aiohttp.WSMsgType.CLOSE,
|
|
aiohttp.WSMsgType.CLOSING,
|
|
):
|
|
error_msg = (
|
|
f"MiniMax connection closed unexpectedly (trace_id: {current_trace_id})"
|
|
)
|
|
logger.error(error_msg)
|
|
raise APIStatusError(
|
|
error_msg,
|
|
request_id=current_trace_id,
|
|
status_code=ws.close_code or -1,
|
|
body=f"{msg.data=} {msg.extra=}",
|
|
)
|
|
|
|
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
logger.warning("unexpected Minimax message type %s", msg.type)
|
|
continue
|
|
|
|
data: dict[str, Any] = json.loads(msg.data)
|
|
|
|
# Extract trace_id (priority: root.trace_id > base_resp.trace_id)
|
|
# api.minimax.io returns trace_id in root.trace_id, api.minimaxi.com may return in base_resp.trace_id
|
|
msg_trace_id = data.get("trace_id") or data.get("base_resp", {}).get("trace_id")
|
|
if msg_trace_id and msg_trace_id != current_trace_id:
|
|
current_trace_id = msg_trace_id
|
|
logger.debug(f"MiniMax WebSocket trace_id updated: {msg_trace_id}")
|
|
|
|
base_resp = data.get("base_resp", {})
|
|
status_code = base_resp.get("status_code", 0)
|
|
if status_code != 0:
|
|
status_msg = base_resp.get("status_msg", "Unknown error")
|
|
error_trace_id = msg_trace_id or current_trace_id
|
|
|
|
logger.error(
|
|
f"MiniMax WebSocket error: code={status_code}, msg={status_msg}, trace_id={error_trace_id}",
|
|
extra={"request_id": request_id, "full_response": data},
|
|
)
|
|
|
|
raise APIStatusError(
|
|
f"MiniMax error [{status_code}]: {status_msg} (trace_id: {error_trace_id})",
|
|
request_id=error_trace_id,
|
|
status_code=status_code,
|
|
body=data,
|
|
)
|
|
|
|
if data.get("event") == "connected_success":
|
|
logger.debug(f"MiniMax WebSocket connected, trace_id={current_trace_id}")
|
|
|
|
elif data.get("event") == "task_started":
|
|
task_started.set_result(None)
|
|
session_id = data.get("session_id", "")
|
|
logger.debug(
|
|
f"MiniMax WebSocket task_started, session_id={session_id}, trace_id={current_trace_id}"
|
|
)
|
|
output_emitter.start_segment(segment_id=session_id)
|
|
|
|
elif data.get("event") == "task_continued":
|
|
audio = data.get("data", {}).get("audio")
|
|
if audio:
|
|
output_emitter.push(bytes.fromhex(audio))
|
|
if data.get("is_final"):
|
|
output_emitter.flush()
|
|
|
|
elif data.get("event") == "task_finished":
|
|
output_emitter.end_input()
|
|
break
|
|
|
|
elif data.get("event") == "task_failed":
|
|
error_msg = (
|
|
f"MiniMax returned task failed (trace_id: {current_trace_id}): {msg.data}"
|
|
)
|
|
logger.error(error_msg)
|
|
raise APIError(error_msg)
|
|
|
|
else:
|
|
logger.warning(f"unexpected Minimax message: {msg.data}")
|
|
|
|
try:
|
|
ws = await self._tts._connect_ws(self._conn_options.timeout)
|
|
tasks = [
|
|
asyncio.create_task(_input_task()),
|
|
asyncio.create_task(_sentence_stream_task(ws)),
|
|
asyncio.create_task(_recv_task(ws)),
|
|
]
|
|
|
|
try:
|
|
await asyncio.gather(*tasks)
|
|
finally:
|
|
await self._tts._close_ws(ws)
|
|
await sentence_stream.aclose()
|
|
await utils.aio.gracefully_cancel(*tasks)
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.error(
|
|
f"MiniMax WebSocket request timeout after {self._conn_options.timeout}s, trace_id={trace_id}"
|
|
)
|
|
raise APITimeoutError(
|
|
f"WebSocket TTS synthesis timed out after {self._conn_options.timeout}s (trace_id: {trace_id})"
|
|
) from None
|
|
except aiohttp.ClientResponseError as e:
|
|
logger.error(
|
|
f"WebSocket HTTP error: status={e.status}, message={e.message}, trace_id={trace_id}",
|
|
exc_info=True,
|
|
)
|
|
raise APIStatusError(
|
|
message=f"WebSocket HTTP {e.status}: {e.message} (trace_id: {trace_id})",
|
|
status_code=e.status,
|
|
request_id=trace_id,
|
|
body=None,
|
|
) from e
|
|
except Exception as e:
|
|
if not isinstance(e, (APIStatusError, APITimeoutError, APIConnectionError)):
|
|
logger.error(
|
|
f"MiniMax WebSocket unexpected error: {type(e).__name__}: {e}, trace_id={trace_id}",
|
|
exc_info=True,
|
|
)
|
|
raise APIConnectionError(
|
|
f"WebSocket connection failed: {type(e).__name__}: {e} (trace_id: {trace_id})"
|
|
) from e
|
|
|
|
async def aclose(self) -> None:
|
|
await super().aclose()
|
|
|
|
|
|
class ChunkedStream(tts.ChunkedStream):
|
|
def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions):
|
|
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
|
|
self._tts: TTS = tts
|
|
self._opts = replace(tts._opts)
|
|
|
|
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
if not self._input_text.strip():
|
|
return
|
|
|
|
url = self._opts.base_url + "/v1/t2a_v2"
|
|
msg = _to_minimax_options(self._opts)
|
|
msg.update(
|
|
{
|
|
"text": self._input_text,
|
|
"stream": True,
|
|
"stream_options": {
|
|
"exclude_aggregated_audio": True, # don't include complete audio in last chunk
|
|
},
|
|
}
|
|
)
|
|
try:
|
|
async with self._tts._ensure_session().post(
|
|
url,
|
|
headers={
|
|
"Authorization": f"Bearer {self._opts.api_key}",
|
|
},
|
|
json=msg,
|
|
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
|
|
# large read_bufsize to avoid `ValueError: Chunk too big`
|
|
read_bufsize=10 * 1024 * 1024,
|
|
) as resp:
|
|
resp.raise_for_status()
|
|
|
|
# Extract trace_id from response headers (all requests have this)
|
|
# Note: api.minimax.io also returns trace_id in response body root.trace_id
|
|
trace_id = resp.headers.get("Trace-Id") or resp.headers.get("X-Trace-Id")
|
|
minimax_request_id = resp.headers.get("Minimax-Request-Id")
|
|
|
|
if trace_id:
|
|
logger.debug(
|
|
f"MiniMax HTTP stream request started, trace_id={trace_id}, minimax_request_id={minimax_request_id}"
|
|
)
|
|
else:
|
|
trace_id = utils.shortuuid()
|
|
logger.warning(
|
|
f"No Trace-Id in response headers, using generated ID: {trace_id}"
|
|
)
|
|
|
|
output_emitter.initialize(
|
|
request_id=trace_id,
|
|
sample_rate=self._opts.sample_rate,
|
|
num_channels=1,
|
|
mime_type=f"audio/{self._opts.audio_format}",
|
|
)
|
|
|
|
async for chunk in resp.content:
|
|
line = chunk.decode().strip()
|
|
if not line:
|
|
continue
|
|
if not line.startswith("data:"):
|
|
logger.warning("unexpected Minimax message: %s", line)
|
|
continue
|
|
|
|
data = json.loads(line[5:])
|
|
|
|
# api.minimax.io returns trace_id in response body root level
|
|
body_trace_id = data.get("trace_id")
|
|
if body_trace_id and body_trace_id != trace_id:
|
|
logger.debug(f"Found trace_id in response body: {body_trace_id}")
|
|
|
|
if audio := data.get("data", {}).get("audio"):
|
|
output_emitter.push(bytes.fromhex(audio))
|
|
else:
|
|
base_resp = data.get("base_resp", {})
|
|
status_code = base_resp.get("status_code", 0)
|
|
if status_code != 0:
|
|
status_msg = base_resp.get("status_msg", "Unknown error")
|
|
# trace_id priority: response body top level > response headers
|
|
error_trace_id = body_trace_id or trace_id
|
|
|
|
logger.error(
|
|
f"MiniMax HTTP stream error: code={status_code}, msg={status_msg}, trace_id={error_trace_id}",
|
|
extra={"full_response": data},
|
|
)
|
|
|
|
raise APIStatusError(
|
|
f"MiniMax error [{status_code}]: {status_msg} (trace_id: {error_trace_id})",
|
|
request_id=error_trace_id,
|
|
body=data,
|
|
)
|
|
output_emitter.flush()
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.error(f"Minimax HTTP stream request timeout after {self._conn_options.timeout}s")
|
|
raise APITimeoutError(
|
|
f"TTS synthesis timed out after {self._conn_options.timeout}s"
|
|
) from None
|
|
except aiohttp.ClientResponseError as e:
|
|
logger.error(f"HTTP error: status={e.status}, message={e.message}", exc_info=True)
|
|
raise APIStatusError(
|
|
message=f"HTTP {e.status}: {e.message}",
|
|
status_code=e.status,
|
|
request_id=None,
|
|
body=None,
|
|
) from e
|
|
except Exception as e:
|
|
if not isinstance(e, (APIStatusError, APITimeoutError, APIConnectionError)):
|
|
logger.error(
|
|
f"Minimax TTS unexpected error: {type(e).__name__}: {e}", exc_info=True
|
|
)
|
|
raise APIConnectionError(f"Connection failed: {type(e).__name__}: {e}") from e
|
|
|
|
|
|
def _to_minimax_options(opts: _TTSOptions) -> dict[str, Any]:
|
|
config: dict[str, Any] = {
|
|
"model": opts.model,
|
|
"voice_setting": {
|
|
"voice_id": opts.voice_id,
|
|
"speed": opts.speed,
|
|
"vol": opts.vol,
|
|
"pitch": opts.pitch,
|
|
},
|
|
"audio_setting": {
|
|
"sample_rate": opts.sample_rate,
|
|
"bitrate": opts.bitrate,
|
|
"format": opts.audio_format,
|
|
"channel": 1,
|
|
},
|
|
"text_normalization": opts.text_normalization,
|
|
}
|
|
|
|
if opts.emotion is not None:
|
|
config["voice_setting"]["emotion"] = opts.emotion
|
|
|
|
if opts.language_boost is not None:
|
|
config["language_boost"] = opts.language_boost
|
|
|
|
if opts.pronunciation_dict:
|
|
config["pronunciation_dict"] = opts.pronunciation_dict
|
|
|
|
voice_modify: dict[str, Any] = {}
|
|
if opts.intensity is not None:
|
|
voice_modify["intensity"] = opts.intensity
|
|
if opts.timbre is not None:
|
|
voice_modify["timbre"] = opts.timbre
|
|
|
|
if voice_modify:
|
|
config["voice_modify"] = voice_modify
|
|
|
|
return config
|