From b4715390d1d3bbdabe1dfa57fed2be55893e8a0d Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:45:08 +0800 Subject: [PATCH] Add MiniMax text-to-speech processor --- .../processors/providers/minimax/__init__.py | 21 ++ llmstack/processors/providers/minimax/tts.py | 154 +++++++++++++++ .../processors/providers/test_minimax_tts.py | 180 ++++++++++++++++++ llmstack/server/settings.py | 6 + 4 files changed, 361 insertions(+) create mode 100644 llmstack/processors/providers/minimax/__init__.py create mode 100644 llmstack/processors/providers/minimax/tts.py create mode 100644 llmstack/processors/providers/test_minimax_tts.py diff --git a/llmstack/processors/providers/minimax/__init__.py b/llmstack/processors/providers/minimax/__init__.py new file mode 100644 index 00000000000..6dbbb108499 --- /dev/null +++ b/llmstack/processors/providers/minimax/__init__.py @@ -0,0 +1,21 @@ +from typing import Literal + +from pydantic import Field + +from llmstack.processors.providers.config import ProviderConfig + + +class MiniMaxProviderConfig(ProviderConfig): + provider_slug: str = "minimax" + api_key: str = Field( + title="API Key", + description="Your MiniMax API key.", + default="", + json_schema_extra={"widget": "password", "advanced_parameter": False}, + ) + speech_api_url: Literal["https://api.minimax.io/v1/t2a_v2", "https://api.minimaxi.com/v1/t2a_v2"] = Field( + default="https://api.minimax.io/v1/t2a_v2", + title="Speech API URL", + description="Use the endpoint for the region of your MiniMax account.", + json_schema_extra={"advanced_parameter": False}, + ) diff --git a/llmstack/processors/providers/minimax/tts.py b/llmstack/processors/providers/minimax/tts.py new file mode 100644 index 00000000000..d9a3284df27 --- /dev/null +++ b/llmstack/processors/providers/minimax/tts.py @@ -0,0 +1,154 @@ +from typing import Literal, Optional + +import requests +from asgiref.sync import async_to_sync +from pydantic import BaseModel, Field, model_validator + +from llmstack.apps.schemas import OutputTemplate +from llmstack.processors.providers.api_processor_interface import ( + AUDIO_WIDGET_NAME, + ApiProcessorInterface, + ApiProcessorSchema, +) + + +class VoiceSetting(BaseModel): + voice_id: str = Field(min_length=1, description="MiniMax system or custom voice ID.") + speed: float = Field(default=1.0, ge=0.5, le=2.0) + vol: float = Field(default=1.0, gt=0.0, le=10.0) + pitch: int = Field(default=0, ge=-12, le=12) + + +class AudioSetting(BaseModel): + format: Literal["mp3", "wav", "flac", "pcm"] = "mp3" + sample_rate: Literal[8000, 16000, 22050, 24000, 32000, 44100] = 32000 + bitrate: Literal[32000, 64000, 128000, 256000] = 128000 + channel: Literal[1, 2] = 1 + + +class PronunciationDict(BaseModel): + tone: list[str] = Field(default_factory=list, description="Pronunciation rules in original/replacement format.") + + +class VoiceModify(BaseModel): + pitch: Optional[int] = Field(default=None, ge=-100, le=100) + intensity: Optional[int] = Field(default=None, ge=-100, le=100) + timbre: Optional[int] = Field(default=None, ge=-100, le=100) + sound_effects: Optional[Literal["spacious_echo", "auditorium_echo", "lofi_telephone", "robotic"]] = None + + +class TextToSpeechInput(ApiProcessorSchema): + input_text: str = Field(min_length=1, max_length=9999, description="Text to convert to speech.") + + +class TextToSpeechConfiguration(ApiProcessorSchema): + model: Literal[ + "speech-2.8-hd", + "speech-2.8-turbo", + "speech-2.6-hd", + "speech-2.6-turbo", + "speech-02-hd", + "speech-02-turbo", + "speech-01-hd", + "speech-01-turbo", + ] = Field( + default="speech-2.8-hd", + description="MiniMax speech model.", + json_schema_extra={"advanced_parameter": False}, + ) + voice_setting: VoiceSetting = Field( + description="Voice ID and speech controls.", + json_schema_extra={"advanced_parameter": False}, + ) + audio_setting: AudioSetting = Field(default_factory=AudioSetting) + language_boost: Optional[str] = Field(default=None, description="Language hint, or auto for automatic detection.") + pronunciation_dict: Optional[PronunciationDict] = None + voice_modify: Optional[VoiceModify] = None + subtitle_enable: bool = False + + @model_validator(mode="after") + def validate_voice_effects_format(self): + if self.voice_modify is not None and self.audio_setting.format == "pcm": + raise ValueError("Voice effects require mp3, wav, or flac audio.") + return self + + +class TextToSpeechOutput(ApiProcessorSchema): + audio_content: str = Field( + description="Reference to the generated audio asset.", + json_schema_extra={"widget": AUDIO_WIDGET_NAME}, + ) + mime_type: str = Field(description="The audio media type.") + subtitle_url: Optional[str] = Field(default=None, description="Download URL for generated subtitles, if requested.") + + +class MiniMaxTextToSpeechProcessor( + ApiProcessorInterface[TextToSpeechInput, TextToSpeechOutput, TextToSpeechConfiguration], +): + @staticmethod + def name() -> str: + return "Text to Speech" + + @staticmethod + def slug() -> str: + return "text_to_speech" + + @staticmethod + def description() -> str: + return "Convert text to speech with MiniMax." + + @staticmethod + def provider_slug() -> str: + return "minimax" + + @classmethod + def get_output_template(cls) -> OutputTemplate: + return OutputTemplate( + markdown='', + jsonpath="$.audio_content", + ) + + def process(self) -> dict: + provider_config = self.get_provider_config(model_slug=self._config.model) + payload = self._config.model_dump(exclude_none=True) + payload.update(text=self._input.input_text, stream=False, output_format="hex") + response = requests.post( + provider_config.speech_api_url, + headers={"Authorization": f"Bearer {provider_config.api_key}"}, + json=payload, + timeout=120, + allow_redirects=False, + ) + response.raise_for_status() + result = response.json() + if not isinstance(result, dict): + raise ValueError("MiniMax returned an invalid speech response.") + base_resp = result.get("base_resp") + if not isinstance(base_resp, dict) or base_resp.get("status_code") != 0: + raise ValueError("MiniMax speech synthesis failed.") + data = result.get("data") + if not isinstance(data, dict) or data.get("status") != 2: + raise ValueError("MiniMax speech synthesis did not complete.") + audio_hex = data.get("audio") + if not isinstance(audio_hex, str) or not audio_hex: + raise ValueError("MiniMax returned no audio.") + try: + audio = bytes.fromhex(audio_hex) + except ValueError as exc: + raise ValueError("MiniMax returned invalid audio encoding.") from exc + if not audio: + raise ValueError("MiniMax returned no audio.") + + audio_format = self._config.audio_setting.format + mime_type = {"mp3": "audio/mpeg", "wav": "audio/wav", "flac": "audio/flac", "pcm": "audio/pcm"}[audio_format] + asset_stream = self._create_asset_stream(mime_type=mime_type, file_name=f"speech.{audio_format}") + asset_stream.append_chunk(audio) + asset_stream.finalize() + async_to_sync(self._output_stream.write)( + TextToSpeechOutput( + audio_content=asset_stream.objref, + mime_type=mime_type, + subtitle_url=data.get("subtitle_file"), + ) + ) + return self._output_stream.finalize() diff --git a/llmstack/processors/providers/test_minimax_tts.py b/llmstack/processors/providers/test_minimax_tts.py new file mode 100644 index 00000000000..1673d5d81ca --- /dev/null +++ b/llmstack/processors/providers/test_minimax_tts.py @@ -0,0 +1,180 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import requests +from pydantic import ValidationError + +from llmstack.common.utils.module_loader import get_all_sub_classes +from llmstack.processors.providers.api_processor_interface import ApiProcessorInterface +from llmstack.processors.providers.minimax import MiniMaxProviderConfig +from llmstack.processors.providers.minimax.tts import ( + MiniMaxTextToSpeechProcessor, + TextToSpeechConfiguration, + TextToSpeechInput, +) +from llmstack.processors.providers.processors import ProcessorFactory +from llmstack.server.settings import PROVIDERS + +EXPECTED_MODELS = [ + "speech-2.8-hd", + "speech-2.8-turbo", + "speech-2.6-hd", + "speech-2.6-turbo", + "speech-02-hd", + "speech-02-turbo", + "speech-01-hd", + "speech-01-turbo", +] +EXPECTED_ENDPOINTS = ["https://api.minimax.io/v1/t2a_v2", "https://api.minimaxi.com/v1/t2a_v2"] + + +class MiniMaxTextToSpeechTest(unittest.TestCase): + def setUp(self): + self.processor = object.__new__(MiniMaxTextToSpeechProcessor) + self.processor._input = TextToSpeechInput(input_text="Hello world.") + self.processor._config = TextToSpeechConfiguration(voice_setting={"voice_id": "test-voice"}) + self.provider = MiniMaxProviderConfig(api_key="test-key") + self.processor.get_provider_config = Mock(return_value=self.provider) + self.asset = Mock(objref="objref://sessionfiles/audio") + self.processor._create_asset_stream = Mock(return_value=self.asset) + self.output = SimpleNamespace( + write=AsyncMock(), finalize=Mock(return_value={"audio_content": self.asset.objref}) + ) + self.processor._output_stream = self.output + self.response = Mock() + self.response.json.return_value = { + "base_resp": {"status_code": 0}, + "data": {"status": 2, "audio": b"audio bytes".hex()}, + } + + def test_registration_and_processor_discovery(self): + registrations = [provider for provider in PROVIDERS if provider["slug"] == "minimax"] + self.assertEqual(len(registrations), 1) + self.assertEqual( + registrations[0]["config_schema"], "llmstack.processors.providers.minimax.MiniMaxProviderConfig" + ) + processors = get_all_sub_classes(registrations[0]["processor_packages"][0], ApiProcessorInterface) + self.assertIn(MiniMaxTextToSpeechProcessor, processors) + self.assertIs(ProcessorFactory.get_processor("text_to_speech", "minimax"), MiniMaxTextToSpeechProcessor) + self.assertEqual(MiniMaxProviderConfig.get_config_schema()["properties"]["api_key"]["widget"], "password") + + @patch("llmstack.processors.providers.minimax.tts.requests.post") + def test_both_regions_and_all_models(self, post): + post.return_value = self.response + schema = TextToSpeechConfiguration.model_json_schema() + self.assertEqual(schema["properties"]["model"]["default"], "speech-2.8-hd") + self.assertEqual(schema["properties"]["model"]["enum"], EXPECTED_MODELS) + self.assertEqual( + MiniMaxProviderConfig.model_json_schema()["properties"]["speech_api_url"]["enum"], EXPECTED_ENDPOINTS + ) + for endpoint in EXPECTED_ENDPOINTS: + for model in EXPECTED_MODELS: + with self.subTest(endpoint=endpoint, model=model): + self.provider.speech_api_url = endpoint + self.processor._config.model = model + result = self.processor.process() + self.assertEqual(result, {"audio_content": self.asset.objref}) + args, kwargs = post.call_args + self.assertEqual(args, (endpoint,)) + self.assertEqual(kwargs["headers"], {"Authorization": "Bearer test-key"}) + self.assertEqual(kwargs["timeout"], 120) + self.assertFalse(kwargs["allow_redirects"]) + self.assertEqual(kwargs["json"]["model"], model) + self.assertEqual(kwargs["json"]["text"], "Hello world.") + self.assertFalse(kwargs["json"]["stream"]) + self.assertEqual(kwargs["json"]["output_format"], "hex") + self.assertNotIn("language_boost", kwargs["json"]) + self.processor.get_provider_config.assert_called_with(model_slug=model) + self.asset.append_chunk.assert_called_with(b"audio bytes") + self.asset.finalize.assert_called_with() + self.assertEqual(self.output.write.call_args.args[0].audio_content, self.asset.objref) + + @patch("llmstack.processors.providers.minimax.tts.requests.post") + def test_optional_parameters_and_subtitle_output(self, post): + post.return_value = self.response + self.response.json.return_value["data"]["subtitle_file"] = "https://example.com/subtitles.json" + self.processor._config = TextToSpeechConfiguration( + voice_setting={"voice_id": "test-voice", "speed": 1.2, "vol": 2, "pitch": 3}, + audio_setting={"format": "wav", "sample_rate": 44100, "channel": 2}, + language_boost="auto", + pronunciation_dict={"tone": ["API/A P I"]}, + voice_modify={"pitch": 10, "sound_effects": "robotic"}, + subtitle_enable=True, + ) + self.processor.process() + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["voice_setting"], {"voice_id": "test-voice", "speed": 1.2, "vol": 2, "pitch": 3}) + self.assertEqual(payload["audio_setting"]["format"], "wav") + self.assertEqual(payload["language_boost"], "auto") + self.assertEqual(payload["pronunciation_dict"], {"tone": ["API/A P I"]}) + self.assertEqual(payload["voice_modify"], {"pitch": 10, "sound_effects": "robotic"}) + self.assertTrue(payload["subtitle_enable"]) + self.assertEqual(self.output.write.call_args.args[0].subtitle_url, "https://example.com/subtitles.json") + + @patch("llmstack.processors.providers.minimax.tts.requests.post") + def test_audio_formats_use_matching_asset_media_types(self, post): + post.return_value = self.response + for audio_format, mime_type in { + "mp3": "audio/mpeg", + "wav": "audio/wav", + "flac": "audio/flac", + "pcm": "audio/pcm", + }.items(): + with self.subTest(audio_format=audio_format): + self.processor._config.audio_setting.format = audio_format + self.processor.process() + self.processor._create_asset_stream.assert_called_with( + mime_type=mime_type, file_name=f"speech.{audio_format}" + ) + self.assertEqual(self.output.write.call_args.args[0].mime_type, mime_type) + + @patch("llmstack.processors.providers.minimax.tts.requests.post") + def test_errors_and_invalid_audio_never_create_assets(self, post): + post.return_value = self.response + responses = [ + [], + {}, + {"base_resp": {"status_code": 1004}, "data": None}, + {"base_resp": {"status_code": 0}, "data": None}, + {"base_resp": {"status_code": 0}, "data": {"status": 1, "audio": "00"}}, + *( + {"base_resp": {"status_code": 0}, "data": {"status": 2, "audio": audio}} + for audio in (None, "", " ", "bad", "zz", 123) + ), + ] + for response in responses: + with self.subTest(response=response): + self.response.json.return_value = response + with self.assertRaises(ValueError): + self.processor.process() + self.processor._create_asset_stream.assert_not_called() + self.output.write.assert_not_called() + + @patch("llmstack.processors.providers.minimax.tts.requests.post") + def test_http_failure_is_propagated(self, post): + post.return_value = self.response + self.response.raise_for_status.side_effect = requests.HTTPError("Request failed") + with self.assertRaises(requests.HTTPError): + self.processor.process() + self.response.json.assert_not_called() + self.processor._create_asset_stream.assert_not_called() + + def test_invalid_request_configuration(self): + for config in ( + {"voice_setting": {"voice_id": ""}}, + {"voice_setting": {"voice_id": "test-voice", "speed": 3}}, + {"voice_setting": {"voice_id": "test-voice", "vol": 0}}, + {"voice_setting": {"voice_id": "test-voice"}, "audio_setting": {"format": "pcm"}, "voice_modify": {}}, + {"voice_setting": {"voice_id": "test-voice"}, "model": "invalid"}, + ): + with self.subTest(config=config), self.assertRaises(ValidationError): + TextToSpeechConfiguration(**config) + with self.assertRaises(ValidationError): + TextToSpeechInput(input_text="") + with self.assertRaises(ValidationError): + MiniMaxProviderConfig(speech_api_url="https://example.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/llmstack/server/settings.py b/llmstack/server/settings.py index b720d01ecf4..ef8db3c500d 100644 --- a/llmstack/server/settings.py +++ b/llmstack/server/settings.py @@ -574,6 +574,12 @@ "slug": "mistral", "config_schema": "llmstack.processors.providers.mistral.MistralProviderConfig", }, + { + "name": "MiniMax", + "processor_packages": ["llmstack.processors.providers.minimax"], + "slug": "minimax", + "config_schema": "llmstack.processors.providers.minimax.MiniMaxProviderConfig", + }, { "name": "Meta", "processor_packages": ["llmstack.processors.providers.meta"],