From 3923b87f0847b9f875171b826a4cdb76381f8c7a Mon Sep 17 00:00:00 2001 From: Li Haoyuan <1513624626@qq.com> Date: Wed, 14 May 2025 11:01:28 +0800 Subject: [PATCH] feat: Add MiniMax TTS API provider --- astrbot/core/config/default.py | 66 ++++++++++ astrbot/core/provider/manager.py | 4 + .../sources/minimax_tts_api_source.py | 120 ++++++++++++++++++ dashboard/src/views/ProviderPage.vue | 59 ++++----- 4 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 astrbot/core/provider/sources/minimax_tts_api_source.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7a8985242..65163a16a 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -788,6 +788,25 @@ CONFIG_METADATA_2 = { "azure_tts_subscription_key": "", "azure_tts_region": "eastus" }, + "MiniMax TTS(API)": { + "id": "minimax_tts", + "type": "minimax_tts_api", + "provider_type": "text_to_speech", + "enable": False, + "api_key": "", + "api_base": "https://api.minimax.chat/v1/t2a_v2", + "minimax-group-id": "", + "model": "speech-02-turbo", + "minimax-langboost": "auto", + "minimax-voice-speed": 1.0, + "minimax-voice-vol": 1.0, + "minimax-voice-pitch": 0, + "minimax-voice-id": "female-shaonv", + "minimax-voice-emotion": "neutral", + "minimax-voice-latex": False, + "minimax-voice-english-normalization": False, + "timeout": "20", + }, }, "items": { "azure_tts_voice": { @@ -911,6 +930,53 @@ CONFIG_METADATA_2 = { }, }, }, + "minimax-group-id": { + "type": "string", + "description": "用户所属的组", + "hint": "于账户管理->基本信息中可见", + }, + "minimax-langboost": { + "type": "string", + "description": "指定语言/方言识别能力", + "hint": "增强对指定的小语种和方言的识别能力,设置后可以提升在指定小语种/方言场景下的语音表现", + "options": [ "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","auto",], + }, + "minimax-voice-speed": { + "type": "float", + "description": "语速取值越大,语速越快", + "hint": "生成声音的语速, 取值[0.5, 2], 默认为1.0, 取值越大,语速越快", + }, + "minimax-voice-vol": { + "type": "float", + "description": "音量", + "hint": "生成声音的音量, 取值(0, 10], 默认为1.0, 取值越大,音量越高", + }, + "minimax-voice-pitch": { + "type": "int", + "description": "语调", + "hint": "生成声音的语调, 取值[-12, 12], 默认为0", + }, + "minimax-voice-id": { + "type": "string", + "description": "音色编号", + "hint": "请求的音色编号, 请见官网文档", + }, + "minimax-voice-emotion": { + "type": "string", + "description": "语音情绪", + "hint": "控制合成语音的情绪", + "options": ["happy","sad","angry","fearful","disgusted","surprised","neutral",], + }, + "minimax-voice-latex": { + "type": "bool", + "description": "是否支持朗读latex公式", + "hint": "", + }, + "minimax-voice-english-normalization": { + "type": "bool", + "description": "是否支持英语文本规范化", + "hint": "可提升数字阅读场景的性能,但会略微增加延迟", + }, "rag_options": { "description": "RAG 选项", "type": "object", diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index e61fbf925..596293ac2 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -206,6 +206,10 @@ class ProviderManager: from .sources.azure_tts_source import ( AzureTTSProvider as AzureTTSProvider, ) + case "minimax_tts_api": + from .sources.minimax_tts_api_source import ( + ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI, + ) except (ImportError, ModuleNotFoundError) as e: logger.critical( f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。" diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py new file mode 100644 index 000000000..52e8ccc46 --- /dev/null +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -0,0 +1,120 @@ +import json +import os +import uuid +from typing import Iterator + +import requests + +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +from ..entities import ProviderType +from ..provider import TTSProvider +from ..register import register_provider_adapter + + +@register_provider_adapter( + "minimax_tts_api", "MiniMax TTS API", provider_type=ProviderType.TEXT_TO_SPEECH +) +class ProviderMiniMaxTTSAPI(TTSProvider): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + self.chosen_api_key: str = provider_config.get("api_key", "") + self.api_base: str = provider_config.get( + "api_base", "https://api.minimax.chat/v1/t2a_v2" + ) + self.group_id: str = provider_config.get("minimax-group-id", "") + self.set_model(provider_config.get("model", "")) + self.lang_boost: str = provider_config.get("minimax-langboost", "auto") + + self.voice_setting: dict = { + "speed": provider_config.get("minimax-voice-speed", 1.0), + "vol": provider_config.get("minimax-voice-vol", 1.0), + "pitch": provider_config.get("minimax-voice-pitch", 0), + "voice_id": provider_config.get("minimax-voice-id", ""), + "emotion": provider_config.get("minimax-voice-emotion", "neutral"), + "latex_read": provider_config.get("minimax-voice-latex", False), + "english_normalization": provider_config.get( + "minimax-voice-english-normalization", False + ), + } + + self.audio_setting: dict = { + "sample_rate": 32000, + "bitrate": 128000, + "format": "mp3", + } + + self.concat_base_url: str = self.api_base + "?GroupId=" + self.group_id + self.headers = { + "Authorization": f"Bearer {self.chosen_api_key}", + "accept": "application/json, text/plain, */*", + "content-type": "application/json", + } + + def _build_tts_stream_body(self, text: str): + """构建流式请求体""" + body = json.dumps( + { + "model": self.model_name, + "text": text, + "stream": True, + "language_boost": self.lang_boost, + "voice_setting": self.voice_setting, + "audio_setting": self.audio_setting, + } + ) + return body + + def _call_tts_stream(self, text: str) -> Iterator[bytes]: + """进行流式请求""" + tts_body = self._build_tts_stream_body(text) + try: + response = requests.request( + "POST", + self.concat_base_url, + stream=True, + headers=self.headers, + data=tts_body, + ) + response.raise_for_status() + for chunk in response.raw: + if chunk: + if chunk[:5] == b"data:": + data = json.loads(chunk[5:]) + if "data" in data and "extra_info" not in data: + if "audio" in data["data"]: + audio = data["data"]["audio"] + yield audio + except requests.exceptions.RequestException as e: + raise Exception(f"MiniMax TTS API请求失败: {str(e)}") + + def _audio_play(self, audio_stream: Iterator[bytes]) -> bytes: + """解码数据流到audio比特流""" + audio = b"" + for chunk in audio_stream: + if chunk is not None and chunk != "\n": + decoded_hex = bytes.fromhex(chunk) + audio += decoded_hex + + return audio + + async def get_audio(self, text: str) -> str: + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3") + + try: + audio_chunk_iterator = self._call_tts_stream(text) + audio = self._audio_play(audio_chunk_iterator) + + # 结果保存至文件 + with open(path, "wb") as file: + file.write(audio) + + return path + + except requests.exceptions.RequestException as e: + raise e diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index 9f27854d6..151aff56b 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -30,7 +30,7 @@ mdi-tag - 提供商类型: + 提供商类型: {{ item.type }} @@ -94,7 +94,7 @@ mdi-close - + @@ -110,14 +110,14 @@ 文字转语音 - + - - @@ -155,17 +155,17 @@ {{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }} {{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商 - + - - + - + @@ -183,7 +183,7 @@ location="top"> {{ save_message }} - + @@ -221,7 +221,7 @@ export default { save_message_success: "success", showConsole: false, - + // 新增提供商对话框相关 showAddProviderDialog: false, activeProviderTab: 'chat_completion', @@ -247,16 +247,16 @@ export default { getTemplatesByType(type) { const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {}; const filtered = {}; - + for (const [name, template] of Object.entries(templates)) { if (template.provider_type === type) { filtered[name] = template; } } - + return filtered; }, - + // 获取提供商类型对应的图标 getProviderIcon(type) { const icons = { @@ -278,6 +278,7 @@ export default { 'LM Studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg', 'FishAudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg', 'Azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg', + 'MiniMax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg', }; for (const key in icons) { if (type.startsWith(key)) { @@ -296,7 +297,7 @@ export default { }; return names[tabType] || tabType; }, - + // 获取提供商简介 getProviderDescription(template, name) { if (name == 'OpenAI') { @@ -304,7 +305,7 @@ export default { } return `${template.type} 服务提供商`; }, - + // 选择提供商模板 selectProviderTemplate(name) { this.newSelectedProviderName = name; @@ -334,7 +335,7 @@ export default { break; } } - + const mergeConfigWithOrder = (target, source, reference) => { // 首先复制所有source中的属性到target if (source && typeof source === 'object' && !Array.isArray(source)) { @@ -348,7 +349,7 @@ export default { } } } - + // 然后根据reference的结构添加或覆盖属性 for (let key in reference) { if (typeof reference[key] === 'object' && reference[key] !== null) { @@ -356,8 +357,8 @@ export default { target[key] = Array.isArray(reference[key]) ? [] : {}; } mergeConfigWithOrder( - target[key], - source && source[key] ? source[key] : {}, + target[key], + source && source[key] ? source[key] : {}, reference[key] ); } else if (!(key in target)) { @@ -366,7 +367,7 @@ export default { } } }; - + if (defaultConfig) { mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig); } @@ -417,7 +418,7 @@ export default { providerStatusChange(provider) { provider.enable = !provider.enable; // 切换状态 - + axios.post('/api/config/provider/update', { id: provider.id, config: provider @@ -429,13 +430,13 @@ export default { this.showError(err.response?.data?.message || err.message); }); }, - + showSuccess(message) { this.save_message = message; this.save_message_success = "success"; this.save_message_snack = true; }, - + showError(message) { this.save_message = message; this.save_message_success = "error"; @@ -475,4 +476,4 @@ export default { .v-window { border-radius: 4px; } - \ No newline at end of file +