From f01905061aab4724dd446fd6a13e72e03a1ae096 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:21:50 +0800 Subject: [PATCH 1/2] Add MiniMax async and WebSocket TTS operations --- .../impl/minimax_model_provider/model/tts.py | 213 +++++++++++++++--- tests/models_provider/test_minimax_tts.py | 209 +++++++++++++++++ 2 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 tests/models_provider/test_minimax_tts.py diff --git a/apps/models_provider/impl/minimax_model_provider/model/tts.py b/apps/models_provider/impl/minimax_model_provider/model/tts.py index 50935e4c915..0ba70929b85 100644 --- a/apps/models_provider/impl/minimax_model_provider/model/tts.py +++ b/apps/models_provider/impl/minimax_model_provider/model/tts.py @@ -1,7 +1,11 @@ # coding=utf-8 +import asyncio +import json from typing import Dict +from urllib.parse import urlsplit, urlunsplit import requests +import websockets from django.utils.translation import gettext as _ @@ -11,6 +15,20 @@ class MiniMaxTextToSpeech(MaxKBBaseModel, BaseTextToSpeech): + ASYNC_REQUEST_FIELDS = { + "voice_setting", + "audio_setting", + "language_boost", + "pronunciation_dict", + "voice_modify", + } + WEBSOCKET_REQUEST_FIELDS = { + "voice_setting", + "audio_setting", + "language_boost", + "pronunciation_dict", + } + api_base: str api_key: str model: str @@ -18,10 +36,10 @@ class MiniMaxTextToSpeech(MaxKBBaseModel, BaseTextToSpeech): def __init__(self, **kwargs): super().__init__(**kwargs) - self.api_key = kwargs.get('api_key') - self.api_base = kwargs.get('api_base') - self.model = kwargs.get('model') - self.params = kwargs.get('params') + self.api_key = kwargs.get("api_key") + self.api_base = kwargs.get("api_base") + self.model = kwargs.get("model") + self.params = kwargs.get("params") or {} @staticmethod def is_cache_model(): @@ -29,49 +47,172 @@ def is_cache_model(): @staticmethod def new_instance(model_type, model_name, model_credential: Dict[str, object], **model_kwargs): - optional_params = {'params': {'voice_id': 'English_Graceful_Lady'}} + optional_params = {"params": {"voice_setting": {"voice_id": "English_Graceful_Lady"}}} for key, value in model_kwargs.items(): - if key not in ['model_id', 'use_local', 'streaming']: - optional_params['params'][key] = value + if key not in ["model_id", "use_local", "streaming"]: + optional_params["params"][key] = value return MiniMaxTextToSpeech( model=model_name, - api_base=model_credential.get('api_base') or 'https://api.minimaxi.com/v1', - api_key=model_credential.get('api_key'), + api_base=model_credential.get("api_base") or "https://api.minimaxi.com/v1", + api_key=model_credential.get("api_key"), **optional_params, ) def check_auth(self): - self.text_to_speech(_('Hello')) + self.text_to_speech(_("Hello")) - def text_to_speech(self, text): - text = _remove_empty_lines(text) - api_base = self.api_base.rstrip('/') - url = f'{api_base}/t2a_v2' - - if 'audio_setting' not in self.params: - self.params['audio_setting'] = {'format': 'mp3', } - payload = { - 'model': self.model, - 'text': text, - 'stream': False, - **self.params, - } - - headers = { - 'Authorization': f'Bearer {self.api_key}', - 'Content-Type': 'application/json', - } - - response = requests.post(url, json=payload, headers=headers, timeout=60) + @staticmethod + def _raise_api_error(result): + base_response = result.get("base_resp", {}) + if base_response.get("status_code", 0) != 0: + error_message = base_response.get("status_msg", "Unknown error") + raise Exception(f"MiniMax TTS API error: {error_message}") + + def _post_tts_operation(self, path, payload): + response = requests.post( + f"{self.api_base.rstrip('/')}/{path.lstrip('/')}", + json=payload, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + timeout=60, + ) response.raise_for_status() result = response.json() - if result.get('base_resp', {}).get('status_code', 0) != 0: - error_msg = result.get('base_resp', {}).get('status_msg', 'Unknown error') - raise Exception(f'MiniMax TTS API error: {error_msg}') + self._raise_api_error(result) + return result + + def _synthesis_params(self, overrides=None): + params = {**self.params, **(overrides or {})} + params.setdefault("audio_setting", {"format": "mp3"}) + return params - audio_hex = result.get('data', {}).get('audio', '') + def _filtered_synthesis_params(self, allowed_fields, overrides=None): + return {key: value for key, value in self._synthesis_params(overrides).items() if key in allowed_fields} + + @staticmethod + def _decode_audio(result): + audio_hex = result.get("data", {}).get("audio", "") if not audio_hex: - raise Exception('MiniMax TTS API returned empty audio data') + raise Exception("MiniMax TTS API returned empty audio data") + try: + return bytes.fromhex(audio_hex) + except ValueError as error: + raise Exception("MiniMax TTS API returned invalid audio data") from error + + def text_to_speech_async_create(self, text, **params): + result = self._post_tts_operation( + "t2a_async_v2", + { + "model": self.model, + "text": _remove_empty_lines(text), + **self._filtered_synthesis_params(self.ASYNC_REQUEST_FIELDS, params), + }, + ) + task_id = result.get("task_id") or result.get("data", {}).get("task_id") + if not task_id: + raise Exception("MiniMax TTS async create returned no task ID") + return task_id + + def text_to_speech_async_query(self, task_id): + return self._post_tts_operation( + "query/t2a_async_query_v2", + {"task_id": task_id}, + ) + + def _websocket_url(self): + parsed_url = urlsplit(self.api_base.rstrip("/")) + if parsed_url.scheme in {"https", "wss"}: + websocket_scheme = "wss" + elif parsed_url.scheme in {"http", "ws"}: + websocket_scheme = "ws" + else: + raise ValueError("MiniMax TTS API URL must use HTTP or HTTPS") + + path_prefix = parsed_url.path.rstrip("/") + if path_prefix.endswith("/v1"): + path_prefix = path_prefix[:-3] + websocket_path = f"{path_prefix}/ws/v1/t2a_v2" + return urlunsplit((websocket_scheme, parsed_url.netloc, websocket_path, "", "")) + + @classmethod + def _load_websocket_message(cls, message): + result = json.loads(message) + if not isinstance(result, dict): + raise Exception("MiniMax TTS WebSocket returned an invalid message") + cls._raise_api_error(result) + return result + + def text_to_speech_websocket(self, text, **params): + text = _remove_empty_lines(text) - return bytes.fromhex(audio_hex) + async def handle(): + headers = {"Authorization": f"Bearer {self.api_key}"} + async with websockets.connect( + self._websocket_url(), + additional_headers=headers, + ping_interval=None, + open_timeout=60, + max_size=None, + ) as websocket: + connected = self._load_websocket_message(await websocket.recv()) + if connected.get("event") != "connected_success": + raise Exception("MiniMax TTS WebSocket connection was not acknowledged") + + await websocket.send( + json.dumps( + { + "event": "task_start", + "model": self.model, + **self._filtered_synthesis_params(self.WEBSOCKET_REQUEST_FIELDS, params), + } + ) + ) + started = self._load_websocket_message(await websocket.recv()) + if started.get("event") != "task_started": + raise Exception("MiniMax TTS WebSocket task was not started") + + await websocket.send( + json.dumps( + { + "event": "task_continue", + "text": text, + } + ) + ) + + audio = bytearray() + while True: + result = self._load_websocket_message(await websocket.recv()) + event = result.get("event") + if event == "task_result": + audio_hex = result.get("data", {}).get("audio", "") + if audio_hex: + try: + audio.extend(bytes.fromhex(audio_hex)) + except ValueError as error: + raise Exception("MiniMax TTS WebSocket returned invalid audio data") from error + elif event == "task_finished": + break + else: + raise Exception(f"MiniMax TTS WebSocket returned unexpected event: {event}") + + if not audio: + raise Exception("MiniMax TTS WebSocket returned empty audio data") + return bytes(audio) + + return asyncio.run(handle()) + + def text_to_speech(self, text): + result = self._post_tts_operation( + "t2a_v2", + { + "model": self.model, + "text": _remove_empty_lines(text), + **self._synthesis_params(), + "stream": False, + }, + ) + return self._decode_audio(result) diff --git a/tests/models_provider/test_minimax_tts.py b/tests/models_provider/test_minimax_tts.py new file mode 100644 index 00000000000..82b10330bb6 --- /dev/null +++ b/tests/models_provider/test_minimax_tts.py @@ -0,0 +1,209 @@ +import importlib.util +import json +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +class DummyMaxKBBaseModel: + def __init__(self, **kwargs): + pass + + +class DummyBaseTextToSpeech: + pass + + +class FakeWebSocket: + def __init__(self, messages): + self.messages = iter(messages) + self.sent = [] + + async def recv(self): + return next(self.messages) + + async def send(self, message): + self.sent.append(message) + + +class FakeWebSocketConnection: + def __init__(self, websocket): + self.websocket = websocket + + async def __aenter__(self): + return self.websocket + + async def __aexit__(self, exc_type, exc_value, traceback): + return False + + +def remove_empty_lines(text): + return "\n".join(line for line in text.splitlines() if line.strip()) + + +def load_tts_module(): + requests_module = types.ModuleType("requests") + requests_module.post = Mock() + websockets_module = types.ModuleType("websockets") + websockets_module.connect = Mock() + + django_module = types.ModuleType("django") + django_utils_module = types.ModuleType("django.utils") + translation_module = types.ModuleType("django.utils.translation") + translation_module.gettext = lambda value: value + + common_module = types.ModuleType("common") + common_utils_module = types.ModuleType("common.utils") + common_utils_common_module = types.ModuleType("common.utils.common") + common_utils_common_module._remove_empty_lines = remove_empty_lines + + models_provider_module = types.ModuleType("models_provider") + base_model_provider_module = types.ModuleType("models_provider.base_model_provider") + base_model_provider_module.MaxKBBaseModel = DummyMaxKBBaseModel + impl_module = types.ModuleType("models_provider.impl") + base_tts_module = types.ModuleType("models_provider.impl.base_tts") + base_tts_module.BaseTextToSpeech = DummyBaseTextToSpeech + + modules = { + "requests": requests_module, + "websockets": websockets_module, + "django": django_module, + "django.utils": django_utils_module, + "django.utils.translation": translation_module, + "common": common_module, + "common.utils": common_utils_module, + "common.utils.common": common_utils_common_module, + "models_provider": models_provider_module, + "models_provider.base_model_provider": base_model_provider_module, + "models_provider.impl": impl_module, + "models_provider.impl.base_tts": base_tts_module, + } + + module_path = Path(__file__).resolve().parents[2] / "apps/models_provider/impl/minimax_model_provider/model/tts.py" + spec = importlib.util.spec_from_file_location("minimax_tts_under_test", module_path) + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, modules): + spec.loader.exec_module(module) + return module, requests_module, websockets_module + + +class MiniMaxTextToSpeechTest(unittest.TestCase): + def setUp(self): + self.module, self.requests, self.websockets = load_tts_module() + self.response = Mock() + self.response.json.return_value = {"base_resp": {"status_code": 0}} + self.requests.post.return_value = self.response + self.model = self.module.MiniMaxTextToSpeech( + api_key="key", + api_base="https://api.minimax.io/v1", + model="speech-test", + params={ + "voice_setting": {"voice_id": "test-voice"}, + "audio_setting": {"format": "mp3"}, + "language_boost": "English", + "pronunciation_dict": {"tone": ["test/t e s t"]}, + "voice_modify": {"pitch": 0}, + "output_format": "hex", + "stream": True, + }, + ) + + def test_new_instance_uses_voice_setting_payload(self): + model = self.module.MiniMaxTextToSpeech.new_instance( + "TTS", + "speech-test", + {"api_base": "https://api.minimax.io/v1", "api_key": "key"}, + ) + + self.assertEqual(model.params["voice_setting"], {"voice_id": "English_Graceful_Lady"}) + self.assertNotIn("voice_id", model.params) + + def test_async_create_posts_supported_fields_and_returns_task_id(self): + self.response.json.return_value = { + "task_id": "task-123", + "base_resp": {"status_code": 0}, + } + + task_id = self.model.text_to_speech_async_create("Hello\n\nworld") + + self.assertEqual(task_id, "task-123") + request = self.requests.post.call_args + self.assertEqual(request.args[0], "https://api.minimax.io/v1/t2a_async_v2") + self.assertEqual( + request.kwargs["json"], + { + "model": "speech-test", + "text": "Hello\nworld", + "voice_setting": {"voice_id": "test-voice"}, + "audio_setting": {"format": "mp3"}, + "language_boost": "English", + "pronunciation_dict": {"tone": ["test/t e s t"]}, + "voice_modify": {"pitch": 0}, + }, + ) + + def test_async_query_posts_task_id(self): + expected = { + "data": {"status": 2, "audio": "0001"}, + "base_resp": {"status_code": 0}, + } + self.response.json.return_value = expected + + result = self.model.text_to_speech_async_query("task-123") + + self.assertEqual(result, expected) + request = self.requests.post.call_args + self.assertEqual( + request.args[0], + "https://api.minimax.io/v1/query/t2a_async_query_v2", + ) + self.assertEqual(request.kwargs["json"], {"task_id": "task-123"}) + + def test_websocket_url_supports_both_regional_api_bases(self): + endpoints = { + "https://api.minimax.io/v1": "wss://api.minimax.io/ws/v1/t2a_v2", + "https://api.minimaxi.com/v1": "wss://api.minimaxi.com/ws/v1/t2a_v2", + } + + for api_base, expected in endpoints.items(): + with self.subTest(api_base=api_base): + self.model.api_base = api_base + self.assertEqual(self.model._websocket_url(), expected) + + def test_websocket_synthesis_combines_audio_frames(self): + websocket = FakeWebSocket( + [ + json.dumps({"event": "connected_success", "base_resp": {"status_code": 0}}), + json.dumps({"event": "task_started", "base_resp": {"status_code": 0}}), + json.dumps({"event": "task_result", "data": {"audio": "0001"}}), + json.dumps({"event": "task_result", "data": {"audio": "ff"}}), + json.dumps({"event": "task_finished", "base_resp": {"status_code": 0}}), + ] + ) + self.websockets.connect.return_value = FakeWebSocketConnection(websocket) + + audio = self.model.text_to_speech_websocket("Hello\n\nworld") + + self.assertEqual(audio, b"\x00\x01\xff") + self.websockets.connect.assert_called_once_with( + "wss://api.minimax.io/ws/v1/t2a_v2", + additional_headers={"Authorization": "Bearer key"}, + ping_interval=None, + open_timeout=60, + max_size=None, + ) + sent_messages = [json.loads(message) for message in websocket.sent] + self.assertEqual(sent_messages[0]["event"], "task_start") + self.assertEqual(sent_messages[0]["model"], "speech-test") + self.assertNotIn("output_format", sent_messages[0]) + self.assertNotIn("stream", sent_messages[0]) + self.assertEqual( + sent_messages[1], + {"event": "task_continue", "text": "Hello\nworld"}, + ) + + +if __name__ == "__main__": + unittest.main() From 11aeb67be5b6c0bc9e1dec3016e77de29ac3d116 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:34:05 +0800 Subject: [PATCH 2/2] Fix MiniMax WebSocket completion flow --- .../impl/minimax_model_provider/model/tts.py | 19 +++++++++++++------ tests/models_provider/test_minimax_tts.py | 6 ++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/models_provider/impl/minimax_model_provider/model/tts.py b/apps/models_provider/impl/minimax_model_provider/model/tts.py index 0ba70929b85..dfc5bca9574 100644 --- a/apps/models_provider/impl/minimax_model_provider/model/tts.py +++ b/apps/models_provider/impl/minimax_model_provider/model/tts.py @@ -187,17 +187,24 @@ async def handle(): while True: result = self._load_websocket_message(await websocket.recv()) event = result.get("event") - if event == "task_result": - audio_hex = result.get("data", {}).get("audio", "") + if event == "task_continued": + audio_hex = (result.get("data") or {}).get("audio", "") if audio_hex: try: audio.extend(bytes.fromhex(audio_hex)) except ValueError as error: raise Exception("MiniMax TTS WebSocket returned invalid audio data") from error - elif event == "task_finished": - break - else: - raise Exception(f"MiniMax TTS WebSocket returned unexpected event: {event}") + if result.get("is_final"): + break + continue + if event == "task_failed": + raise Exception("MiniMax TTS WebSocket task failed") + raise Exception(f"MiniMax TTS WebSocket returned unexpected event: {event}") + + await websocket.send(json.dumps({"event": "task_finish"})) + finished = self._load_websocket_message(await websocket.recv()) + if finished.get("event") != "task_finished": + raise Exception("MiniMax TTS WebSocket task was not finished") if not audio: raise Exception("MiniMax TTS WebSocket returned empty audio data") diff --git a/tests/models_provider/test_minimax_tts.py b/tests/models_provider/test_minimax_tts.py index 82b10330bb6..13f8c6d9a58 100644 --- a/tests/models_provider/test_minimax_tts.py +++ b/tests/models_provider/test_minimax_tts.py @@ -177,8 +177,9 @@ def test_websocket_synthesis_combines_audio_frames(self): [ json.dumps({"event": "connected_success", "base_resp": {"status_code": 0}}), json.dumps({"event": "task_started", "base_resp": {"status_code": 0}}), - json.dumps({"event": "task_result", "data": {"audio": "0001"}}), - json.dumps({"event": "task_result", "data": {"audio": "ff"}}), + json.dumps({"event": "task_continued", "data": {"audio": "0001"}, "is_final": False}), + json.dumps({"event": "task_continued", "data": None, "is_final": False}), + json.dumps({"event": "task_continued", "data": {"audio": "ff"}, "is_final": True}), json.dumps({"event": "task_finished", "base_resp": {"status_code": 0}}), ] ) @@ -203,6 +204,7 @@ def test_websocket_synthesis_combines_audio_frames(self): sent_messages[1], {"event": "task_continue", "text": "Hello\nworld"}, ) + self.assertEqual(sent_messages[2], {"event": "task_finish"}) if __name__ == "__main__":