MiniMax TTS default model and endpoint updates
Cluster 20869 · skills. Data as of
Nothing open on GitHub. Fix merged in #25431 on 14 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #25431. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address outdated MiniMax TTS configuration defaults, specifically updating the model from speech-01 to speech-02 and correcting the API endpoint URL. The members appear to cover the same fix from multiple angles or iterations.
Open issues0
Open PRs0
Fixes on record
-
#25431 fix(tts): MiniMax TTS — switch defaults to live t2a_v2 endpoint
files:
scripts/release.py,tests/tools/test_tts_speed.py,tools/tts_tool.pyShow the diff
-
scripts/release.py@@ -223,6 +223,7 @@ "[redacted:email]": "htsh", "[redacted:email]": "pty819", "[email protected]": "pty819", + "[email protected]": "pty819", "[redacted:email]": "chennest", # Curator fixes (Apr 30 2026) "[redacted:email]": "y0shua1ee", -
tests/tools/test_tts_speed.py@@ -8,7 +8,12 @@ @pytest.fixture(autouse=True) def clean_env(monkeypatch): - for key in ("OPENAI_API_KEY", "MINIMAX_API_KEY", "HERMES_SESSION_PLATFORM"): + for key in ( + "OPENAI_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_GROUP_ID", + "HERMES_SESSION_PLATFORM", + ): monkeypatch.delenv(key, raising=False) @@ -110,37 +115,126 @@ def test_speed_clamped_high(self, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -# MiniMax TTS (new API: raw audio, no speed/voice_setting) +# MiniMax TTS (t2a_v2 endpoint: nested voice_setting/audio_setting, +# JSON response with hex-encoded audio. Falls back to the legacy +# text_to_speech endpoint shape when the base_url points at it.) # --------------------------------------------------------------------------- -class TestMinimaxTtsSpeed: + +def _hex_response(payload_audio: bytes = b"\x00\x01\x02\x03"): + """Build a mock response shaped like a successful t2a_v2 reply.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = { + "data": {"audio": payload_audio.hex(), "status": 2}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + } + return mock_response + + +class TestMinimaxTtsT2aV2: + """Default path: base_url contains 't2a_v2'.""" + + def _run(self, tts_config, tmp_path, monkeypatch, response=None): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + resp = response if response is not None else _hex_response() + with patch("requests.post", return_value=resp) as mock_post: + from tools.tts_tool import _generate_minimax_tts + output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + return mock_post, output + + def test_nested_payload(self, tmp_path, monkeypatch): + """Default endpoint uses nested voice_setting / audio_setting.""" + mock_post, _ = self._run({}, tmp_path, monkeypatch) + payload = mock_post.call_args[1]["json"] + assert payload["model"] == "speech-02-hd" + assert payload["text"] == "Hello" + assert "voice_setting" in payload + assert payload["voice_setting"]["voice_id"] == "English_expressive_narrator" + assert "audio_setting" in payload + assert payload["audio_setting"]["format"] == "mp3" + # Don't send flat top-level voice_id alongside nested voice_setting. + assert "voice_id" not in payload + + def test_decodes_hex_audio(self, tmp_path, monkeypatch): + """t2a_v2 hex-encoded audio is decoded and written verbatim.""" + _, output = self._run({}, tmp_path, monkeypatch) + with open(output, "rb") as f: + assert f.read() == b"\x00\x01\x02\x03" + + def test_default_url_is_t2a_v2(self, tmp_path, monkeypatch): + """Default base URL points at the live t2a_v2 endpoint.""" + mock_post, _ = self._run({}, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert "t2a_v2" in url + assert "api.minimax.io" in url + + def test_group_id_from_config(self, tmp_path, monkeypatch): + """group_id from config attaches as ?GroupId=<id>.""" + mock_post, _ = self._run({"minimax": {"group_id": "G123"}}, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert "GroupId=G123" in url + + def test_group_id_from_env(self, tmp_path, monkeypatch): + """MINIMAX_GROUP_ID env var attaches as ?GroupId=<id>.""" + monkeypatch.setenv("MINIMAX_GROUP_ID", "G456") + mock_post, _ = self._run({}, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert "GroupId=G456" in url + + def test_group_id_already_in_url_left_alone(self, tmp_path, monkeypatch): + """If user already set GroupId in base_url, don't double-append it.""" + cfg = {"minimax": { + "base_url": "https://api.minimax.io/v1/t2a_v2?GroupId=PRESET", + "group_id": "IGNORED", + }} + mock_post, _ = self._run(cfg, tmp_path, monkeypatch) + url = mock_post.call_args[0][0] + assert url.count("GroupId=") == 1 + assert "GroupId=PRESET" in url + + def test_api_error_raises(self, tmp_path, monkeypatch): + """Non-zero base_resp.status_code surfaces as RuntimeError.""" + resp = MagicMock() + resp.status_code = 200 + resp.headers = {"Content-Type": "application/json"} + resp.json.return_value = { + "data": {"audio": "", "status": 1}, + "base_resp": {"status_code": 2013, "status_msg": "invalid voice"}, + } + with pytest.raises(RuntimeError, match="2013"): + self._run({}, tmp_path, monkeypatch, response=resp) + + +class TestMinimaxTtsLegacyTextToSpeech: + """Legacy path: caller pins base_url to the old text_to_speech endpoint.""" + + LEGACY_URL = "https://api.minimax.chat/v1/text_to_speech" + def _run(self, tts_config, tmp_path, monkeypatch): monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + cfg = dict(tts_config) + cfg.setdefault("minimax", {})["base_url"] = self.LEGACY_URL mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"Content-Type": "audio/mpeg"} mock_response.content = b"\x00\x01\x02\x03" - - # requests is imported locally inside _generate_minimax_tts with patch("requests.post", return_value=mock_response) as mock_post: from tools.tts_tool import _generate_minimax_tts - output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), cfg) return mock_post, output - def test_simple_payload(self, tmp_path, monkeypatch): - """New API uses flat payload with model, text, voice_id.""" + def test_flat_payload(self, tmp_path, monkeypatch): + """Legacy endpoint keeps the flat {model, text, voice_id} shape.""" mock_post, _ = self._run({}, tmp_path, monkeypatch) payload = mock_post.call_args[1]["json"] - assert "model" in payload - assert "text" in payload assert "voice_id" in payload assert "voice_setting" not in payload assert "audio_setting" not in payload - assert "stream" not in payload def test_writes_raw_audio(self, tmp_path, monkeypatch): - """New API returns raw bytes written directly to file.""" + """Legacy endpoint returns raw bytes written directly to file.""" _, output = self._run({}, tmp_path, monkeypatch) - assert output == str(tmp_path / "out.mp3") with open(output, "rb") as f: assert f.read() == b"\x00\x01\x02\x03" -
tools/tts_tool.py@@ -159,9 +159,9 @@ def _import_piper(): DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality DEFAULT_OPENAI_VOICE = "alloy" DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" -DEFAULT_MINIMAX_MODEL = "speech-01" -DEFAULT_MINIMAX_VOICE_ID = "female-shaonv" -DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech" +DEFAULT_MINIMAX_MODEL = "speech-02-hd" +DEFAULT_MINIMAX_VOICE_ID = "English_expressive_narrator" +DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2" DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603" DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral DEFAULT_XAI_VOICE_ID = "eve" @@ -960,11 +960,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) - # =========================================================================== def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str: """ - Generate audio using MiniMax TTS API (v1/text_to_speech). + Generate audio using MiniMax TTS API. - The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload - and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike - the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio. + Supports two endpoints: + - v1/text_to_speech: simple payload, returns raw audio (Content-Type: audio/mpeg) + - v1/t2a_v2: nested voice_setting/audio_setting, returns JSON with hex-encoded audio Args: text: Text to convert (max 10,000 characters). @@ -984,56 +984,106 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any model = mm_config.get("model", DEFAULT_MINIMAX_MODEL) voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID) base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL) - - payload = { - "model": model, - "text": text, - "voice_id": voice_id, - } + speed = mm_config.get("speed", 1.0) + vol = mm_config.get("vol", 1.0) + pitch = mm_config.get("pitch", 0) + emotion = mm_config.get("emotion", "neutral") + sample_rate = mm_config.get("sample_rate", 32000) + bitrate = mm_config.get("bitrate", 128000) + + # MiniMax accounts scope TTS requests by GroupId. When present, the docs + # show it as a ?GroupId=<id> query param on the t2a_v2 URL. Accept it + # from config or from the MINIMAX_GROUP_ID env var; only attach when the + # URL doesn't already carry one. + group_id = ( + str(mm_config.get("group_id") or "").strip() + or (get_env_value("MINIMAX_GROUP_ID") or "").strip() + ) + if group_id and "GroupId=" not in base_url: + sep = "&" if "?" in base_url else "?" + base_url = f"{base_url}{sep}GroupId={group_id}" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", } - response = requests.post(base_url, json=payload, headers=headers, timeout=60) + # Detect endpoint from URL + is_t2a_v2 = "t2a_v2" in base_url - content_type = response.headers.get("Content-Type", "") + if is_t2a_v2: + # t2a_v2 endpoint: nested voice_setting/audio_setting structure + payload = { + "model": model, + "text": text, + "voice_setting": { + "voice_id": voice_id, + "speed": speed, + "vol": vol, + "pitch": pitch, + "emotion": emotion, + }, + "audio_setting": { + "sample_rate": sample_rate, + "bitrate": bitrate, + "format": "mp3", + "channel": 1, + }, + } + else: + # text_to_speech endpoint: flat payload + payload = { + "model": model, + "text": text, + "voice_id": voice_id, + } - if "audio/" in content_type: - # New API: returns raw audio directly - with open(output_path, "wb") as f: - f.write(response.content) - return output_path + response = requests.post(base_url, json=payload, headers=headers, timeout=60) - # Legacy / fallback: try parsing as JSON with hex-encoded audio - try: + if is_t2a_v2: + # t2a_v2 returns JSON with hex-encoded audio result = response.json() - except Exception: - response.raise_for_status() - raise RuntimeError( - f"MiniMax TTS returned unexpected Content-Type '{content_type}' " - f"({len(response.content)} bytes)" - ) + base_resp = result.get("base_resp", {}) + status_code = base_resp.get("status_code", -1) - base_resp = result.get("base_resp", {}) - status_code = base_resp.get("status_code", -1) + if status_code != 0: + status_msg = base_resp.get("status_msg", "unknown error") + raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}") - if status_code != 0: - status_msg = base_resp.get("status_msg", "unknown error") - raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}") + hex_audio = result.get("data", {}).get("audio", "") + if not hex_audio: + raise RuntimeError("MiniMax TTS returned empty audio data") - hex_audio = result.get("data", {}).get("audio", "") - if not hex_audio: - raise RuntimeError("MiniMax TTS returned empty audio data") + audio_bytes = bytes.fromhex(hex_audio) + with open(output_path, "wb") as f: + f.write(audio_bytes) + return output_path - # Legacy: hex-encoded audio - audio_bytes = bytes.fromhex(hex_audio) + else: + # text_to_speech returns raw audio directly + content_type = response.headers.get("Content-Type", "") - with open(output_path, "wb") as f: - f.write(audio_bytes) + if "audio/" in content_type: + with open(output_path, "wb") as f: + f.write(response.content) + return output_path - return output_path + # Fallback: try parsing as JSON + try: + result = response.json() + base_resp = result.get("base_resp", {}) + status_code = base_resp.get("status_code", -1) + if status_code != 0: + status_msg = base_resp.get("status_msg", "unknown error") + raise RuntimeError(f"MiniMax TTS API error (code {status_code}): {status_msg}") + except Exception: + response.raise_for_status() + raise RuntimeError( + f"MiniMax TTS returned unexpected Content-Type '{content_type}' " + f"({len(response.content)} bytes)" + ) + + raise RuntimeError("MiniMax TTS returned no audio data") # ===========================================================================
-
2 PRs closed without merging
From the close comments: duplicate 1 · merged via another change 1
Closed issues · 1
- Closed#20869fix(tts): MiniMax TTS defaults are outdated — model speech-01 and old API endpoint
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- skills
- Merged PRs
- #25431
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 14 May 2026