diff --git a/README.md b/README.md index 7478b99d..59609ecc 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ cow install-browser + 添加 `"speech_recognition": true` 将开启语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图); + 添加 `"group_speech_recognition": true` 将开启群组语音识别,默认使用 openai 的 whisper 模型识别为文字,同时以文字回复,参数仅支持群聊 (会匹配 group_chat_prefix 和 group_chat_keyword, 支持语音触发画图); + 添加 `"voice_reply_voice": true` 将开启语音回复语音(同时作用于私聊和群聊) ++ 使用 MiniMax TTS:设置 `"text_to_voice": "minimax"`,并配置 `minimax_api_key`;可通过 `"tts_voice_id"` 指定发音人(如 `English_Graceful_Lady`),`"text_to_voice_model"` 指定模型(如 `speech-2.8-hd`、`speech-2.8-turbo`)
@@ -357,7 +358,7 @@ sudo docker logs -f chatgpt-on-wechat "minimax_api_key": "" } ``` - - `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等 + - `model`: 可填写 `MiniMax-M2.7、MiniMax-M2.7-highspeed、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2、abab6.5-chat` 等 - `minimax_api_key`:MiniMax 平台的 API-KEY,在 [控制台](https://platform.minimaxi.com/user-center/basic-information/interface-key) 创建 方式二:OpenAI 兼容方式接入,配置如下: @@ -370,7 +371,7 @@ sudo docker logs -f chatgpt-on-wechat } ``` - `bot_type`: OpenAI 兼容方式 -- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek) +- `model`: 可填 `MiniMax-M2.7、MiniMax-M2.7-highspeed、MiniMax-M2.5、MiniMax-M2.1、MiniMax-M2.1-lightning、MiniMax-M2`,参考[API文档](https://platform.minimaxi.com/document/%E5%AF%B9%E8%AF%9D?key=66701d281d57f38758d581d0#QklxsNSbaf6kM4j6wjO5eEek) - `open_ai_api_base`: MiniMax 平台 API 的 BASE URL - `open_ai_api_key`: MiniMax 平台的 API-KEY
diff --git a/common/const.py b/common/const.py index f7e67e52..ecaf5b0f 100644 --- a/common/const.py +++ b/common/const.py @@ -93,6 +93,7 @@ QWQ_PLUS = "qwq-plus" # MiniMax MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest +MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5 MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1 MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版 @@ -175,7 +176,7 @@ MODEL_LIST = [ QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG, # MiniMax - MiniMax, MINIMAX_M2_7, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5, + MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5, # GLM ZHIPU_AI, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS, diff --git a/models/minimax/minimax_bot.py b/models/minimax/minimax_bot.py index af80e795..0fd45e66 100644 --- a/models/minimax/minimax_bot.py +++ b/models/minimax/minimax_bot.py @@ -20,7 +20,7 @@ class MinimaxBot(Bot): def __init__(self): super().__init__() self.args = { - "model": conf().get("model") or "MiniMax-M2.1", + "model": conf().get("model") or "MiniMax-M2.7", "temperature": conf().get("temperature", 0.3), "top_p": conf().get("top_p", 0.95), } diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 00000000..cfad7fd7 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,184 @@ +# encoding:utf-8 +""" +Unit tests for MiniMax provider additions: + - MiniMax-M2.7-highspeed constant in const.py + - Default model update in MinimaxBot + - MinimaxVoice TTS provider +""" +import sys +import os +import json +import unittest +from unittest.mock import MagicMock, patch, PropertyMock + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +class TestMinimaxConst(unittest.TestCase): + """Test that MiniMax-M2.7-highspeed is properly registered in const.py.""" + + def test_m2_7_highspeed_constant_defined(self): + from common import const + self.assertTrue(hasattr(const, "MINIMAX_M2_7_HIGHSPEED")) + self.assertEqual(const.MINIMAX_M2_7_HIGHSPEED, "MiniMax-M2.7-highspeed") + + def test_m2_7_constant_defined(self): + from common import const + self.assertEqual(const.MINIMAX_M2_7, "MiniMax-M2.7") + + def test_m2_7_highspeed_in_model_list(self): + from common import const + self.assertIn("MiniMax-M2.7-highspeed", const.MODEL_LIST) + + def test_m2_7_in_model_list(self): + from common import const + self.assertIn("MiniMax-M2.7", const.MODEL_LIST) + + def test_minimax_provider_key_defined(self): + from common import const + self.assertEqual(const.MiniMax, "minimax") + + +class TestMinimaxBotDefaultModel(unittest.TestCase): + """Test that MinimaxBot defaults to MiniMax-M2.7.""" + + def test_default_model_is_m2_7(self): + # Patch conf() to return empty config + mock_conf = MagicMock() + mock_conf.get = MagicMock(side_effect=lambda key, default=None: default) + + with patch("models.minimax.minimax_bot.conf", return_value=mock_conf): + with patch("models.minimax.minimax_bot.SessionManager"): + from models.minimax import minimax_bot + # Reload to pick up patches + import importlib + importlib.reload(minimax_bot) + with patch("models.minimax.minimax_bot.conf", return_value=mock_conf): + bot = minimax_bot.MinimaxBot.__new__(minimax_bot.MinimaxBot) + bot.args = { + "model": mock_conf.get("model") or "MiniMax-M2.7", + } + self.assertEqual(bot.args["model"], "MiniMax-M2.7") + + def test_default_model_string(self): + """Verify the fallback string literal in minimax_bot.py is MiniMax-M2.7.""" + import ast + bot_path = os.path.join(os.path.dirname(__file__), "..", "models", "minimax", "minimax_bot.py") + with open(bot_path) as f: + source = f.read() + # Verify MiniMax-M2.7 is in the source (not M2.1) + self.assertIn("MiniMax-M2.7", source) + self.assertNotIn('"MiniMax-M2.1"', source) + + +class TestMinimaxVoice(unittest.TestCase): + """Test MinimaxVoice TTS provider.""" + + def _make_voice(self, api_key="test-key", api_base="https://api.minimax.io/v1"): + mock_conf = MagicMock() + def conf_get(key, default=None): + return { + "minimax_api_key": api_key, + "minimax_api_base": api_base, + }.get(key, default) + mock_conf.get = conf_get + with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf): + from voice.minimax.minimax_voice import MinimaxVoice + return MinimaxVoice() + + def test_instantiation(self): + voice = self._make_voice() + self.assertIsNotNone(voice) + + def test_api_base_strips_v1_suffix(self): + voice = self._make_voice(api_base="https://api.minimax.io/v1") + self.assertEqual(voice.api_base, "https://api.minimax.io") + + def test_api_base_no_trailing_slash(self): + voice = self._make_voice(api_base="https://api.minimax.io") + self.assertEqual(voice.api_base, "https://api.minimax.io") + + def test_voice_to_text_not_supported(self): + voice = self._make_voice() + with self.assertRaises(NotImplementedError): + voice.voiceToText("dummy.wav") + + def test_text_to_voice_success(self): + """Test textToVoice with mocked SSE stream response.""" + import os + os.makedirs("tmp", exist_ok=True) + + # Build fake SSE stream bytes + audio_hex = bytes([0x49, 0x44, 0x33]).hex() # "ID3" MP3 magic bytes + sse_line = f'data: {{"data": {{"audio": "{audio_hex}", "status": 2}}}}\n\n' + done_line = "data: [DONE]\n\n" + fake_body = (sse_line + done_line).encode("utf-8") + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.iter_lines.return_value = [ + line.encode("utf-8") for line in (sse_line + done_line).splitlines() if line + ] + + mock_conf = MagicMock() + def conf_get(key, default=None): + return { + "minimax_api_key": "test-key", + "minimax_api_base": "https://api.minimax.io", + }.get(key, default) + mock_conf.get = conf_get + + with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf): + with patch("voice.minimax.minimax_voice.requests.post", return_value=mock_response): + from voice.minimax import minimax_voice + import importlib + importlib.reload(minimax_voice) + with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf): + voice = minimax_voice.MinimaxVoice() + from bridge.reply import ReplyType + reply = voice.textToVoice("Hello, world!") + self.assertEqual(reply.type, ReplyType.VOICE) + self.assertTrue(reply.content.endswith(".mp3")) + + def test_text_to_voice_no_audio_returns_error(self): + """Test that empty SSE stream returns an ERROR reply.""" + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.iter_lines.return_value = [] + + mock_conf = MagicMock() + def conf_get(key, default=None): + return { + "minimax_api_key": "test-key", + "minimax_api_base": "https://api.minimax.io", + }.get(key, default) + mock_conf.get = conf_get + + with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf): + with patch("voice.minimax.minimax_voice.requests.post", return_value=mock_response): + from voice.minimax import minimax_voice + import importlib + importlib.reload(minimax_voice) + with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf): + voice = minimax_voice.MinimaxVoice() + from bridge.reply import ReplyType + reply = voice.textToVoice("Hello") + self.assertEqual(reply.type, ReplyType.ERROR) + + +class TestVoiceFactory(unittest.TestCase): + """Test that minimax is registered in the voice factory.""" + + def test_minimax_voice_factory(self): + mock_conf = MagicMock() + mock_conf.get = MagicMock(return_value=None) + with patch("voice.minimax.minimax_voice.conf", return_value=mock_conf): + from voice.factory import create_voice + voice = create_voice("minimax") + from voice.minimax.minimax_voice import MinimaxVoice + self.assertIsInstance(voice, MinimaxVoice) + + +if __name__ == "__main__": + unittest.main() diff --git a/voice/factory.py b/voice/factory.py index 8562f634..abe7ba57 100644 --- a/voice/factory.py +++ b/voice/factory.py @@ -54,4 +54,8 @@ def create_voice(voice_type): from voice.tencent.tencent_voice import TencentVoice return TencentVoice() + elif voice_type == "minimax": + from voice.minimax.minimax_voice import MinimaxVoice + + return MinimaxVoice() raise RuntimeError diff --git a/voice/minimax/__init__.py b/voice/minimax/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice/minimax/minimax_voice.py b/voice/minimax/minimax_voice.py new file mode 100644 index 00000000..1446a3f1 --- /dev/null +++ b/voice/minimax/minimax_voice.py @@ -0,0 +1,106 @@ +# encoding:utf-8 +""" +MiniMax TTS voice service +""" +import datetime +import random +import requests + +from bridge.reply import Reply, ReplyType +from common.log import logger +from config import conf +from voice.voice import Voice + + +MINIMAX_TTS_VOICES = [ + "English_Graceful_Lady", + "English_Insightful_Speaker", + "English_radiant_girl", + "English_Persuasive_Man", + "English_Lucky_Robot", + "English_expressive_narrator", + "Chinese_Warm_Woman", + "Chinese_Gentle_Man", +] + + +class MinimaxVoice(Voice): + def __init__(self): + self.api_key = conf().get("minimax_api_key") + self.api_base = conf().get("minimax_api_base") or "https://api.minimax.io" + # Strip trailing /v1 if present so we can always append /v1/t2a_v2 + self.api_base = self.api_base.rstrip("/") + if self.api_base.endswith("/v1"): + self.api_base = self.api_base[:-3] + + def voiceToText(self, voice_file): + """MiniMax does not provide an ASR endpoint; raise NotImplementedError.""" + raise NotImplementedError("MiniMax voice-to-text is not supported") + + def textToVoice(self, text): + try: + model = conf().get("text_to_voice_model") or "speech-2.8-hd" + voice_id = conf().get("tts_voice_id") or "English_Graceful_Lady" + + url = f"{self.api_base}/v1/t2a_v2" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + payload = { + "model": model, + "text": text, + "stream": True, + "voice_setting": { + "voice_id": voice_id, + "speed": 1, + "vol": 1, + "pitch": 0, + }, + "audio_setting": { + "sample_rate": 32000, + "bitrate": 128000, + "format": "mp3", + "channel": 1, + }, + } + + response = requests.post(url, headers=headers, json=payload, stream=True, timeout=60) + response.raise_for_status() + + # Parse SSE stream and collect hex-encoded audio chunks + audio_chunks = [] + buffer = "" + for raw in response.iter_lines(): + if not raw: + continue + line = raw.decode("utf-8") if isinstance(raw, bytes) else raw + if not line.startswith("data:"): + continue + json_str = line[5:].strip() + if not json_str or json_str == "[DONE]": + continue + try: + import json + event_data = json.loads(json_str) + audio_hex = event_data.get("data", {}).get("audio") + if audio_hex: + audio_chunks.append(bytes.fromhex(audio_hex)) + except Exception: + continue + + if not audio_chunks: + logger.error("[MINIMAX] TTS returned no audio data") + return Reply(ReplyType.ERROR, "语音合成失败,未获取到音频数据") + + audio_data = b"".join(audio_chunks) + file_name = "tmp/" + datetime.datetime.now().strftime("%Y%m%d%H%M%S") + str(random.randint(0, 1000)) + ".mp3" + with open(file_name, "wb") as f: + f.write(audio_data) + + logger.info(f"[MINIMAX] textToVoice success, file={file_name}") + return Reply(ReplyType.VOICE, file_name) + + except Exception as e: + logger.error(f"[MINIMAX] textToVoice error: {e}") + return Reply(ReplyType.ERROR, "遇到了一点小问题,请稍后再试")