diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 76b6c49d..0f648ee0 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -820,6 +820,27 @@ 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-is-timber-weight": False, + "minimax-voice-id": "female-shaonv", + "minimax-timber-weight": '[\n {\n "voice_id": "Chinese (Mandarin)_Warm_Girl",\n "weight": 25\n },\n {\n "voice_id": "Chinese (Mandarin)_BashfulGirl",\n "weight": 50\n }\n]', + "minimax-voice-emotion": "neutral", + "minimax-voice-latex": False, + "minimax-voice-english-normalization": False, + "timeout": 20, + }, }, "items": { "azure_tts_voice": { @@ -943,6 +964,64 @@ 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-is-timber-weight": { + "type": "bool", + "description": "启用混合音色", + "hint": "启用混合音色, 支持以自定义权重混合最多四种音色, 启用后自动忽略单一音色设置", + }, + "minimax-timber-weight": { + "type": "string", + "description": "混合音色", + "editor_mode": True, + "hint": "混合音色及其权重, 最多支持四种音色, 权重为整数, 取值[1, 100]. 可在官网API语音调试台预览代码获得预设以及编写模板, 需要严格按照json字符串格式编写, 可以查看控制台判断是否解析成功. 具体结构可参照默认值以及官网代码预览.", + }, + "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": "朗读latex公式, 但是需要确保输入文本按官网要求格式化", + }, + "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 e61fbf92..596293ac 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 00000000..5b210835 --- /dev/null +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -0,0 +1,149 @@ +import json +import os +import uuid +import aiohttp +from typing import Dict, List, Union, AsyncIterator +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.api import logger +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.is_timber_weight: bool = provider_config.get( + "minimax-is-timber-weight", False + ) + self.timber_weight: List[Dict[str, Union[str, int]]] = json.loads( + provider_config.get( + "minimax-timber-weight", + '[{"voice_id": "Chinese (Mandarin)_Warm_Girl", "weight": 1}]', + ) + ) + + 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": "" + if self.is_timber_weight + else 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 = f"{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): + """构建流式请求体""" + dict_body: Dict[str, object] = { + "model": self.model_name, + "text": text, + "stream": True, + "language_boost": self.lang_boost, + "voice_setting": self.voice_setting, + "audio_setting": self.audio_setting, + } + if self.is_timber_weight: + dict_body["timber_weights"] = self.timber_weight + + return json.dumps(dict_body) + + async def _call_tts_stream(self, text: str) -> AsyncIterator[bytes]: + """进行流式请求""" + try: + async with aiohttp.ClientSession() as session: + async with session.post( + self.concat_base_url, + headers=self.headers, + data=self._build_tts_stream_body(text), + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + response.raise_for_status() + + buffer = b"" + while True: + chunk = await response.content.read(8192) + if not chunk: + break + + buffer += chunk + + while b"\n\n" in buffer: + try: + message, buffer = buffer.split(b"\n\n", 1) + if message.startswith(b"data: "): + try: + data = json.loads(message[6:]) + if "extra_info" in data: + continue + audio = data.get("data", {}).get("audio") + if audio is not None: + yield audio + except json.JSONDecodeError: + logger.warning( + "Failed to parse JSON data from SSE message" + ) + continue + except ValueError: + buffer = buffer[-1024:] + + except aiohttp.ClientError as e: + raise Exception(f"MiniMax TTS API请求失败: {str(e)}") + + async def _audio_play(self, audio_stream: AsyncIterator[str]) -> bytes: + """解码数据流到 audio 比特流""" + chunks = [] + async for chunk in audio_stream: + if chunk.strip(): + chunks.append(bytes.fromhex(chunk.strip())) + return b"".join(chunks) + + async def get_audio(self, text: str) -> str: + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + os.makedirs(temp_dir, exist_ok=True) + path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3") + + try: + # 直接将异步生成器传递给 _audio_play 方法 + audio_stream = self._call_tts_stream(text) + audio = await self._audio_play(audio_stream) + + # 结果保存至文件 + with open(path, "wb") as file: + file.write(audio) + + return path + + except aiohttp.ClientError as e: + raise e diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index e98ef16c..5b339c70 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -4,20 +4,19 @@ import { ref } from 'vue' const dialog = ref(false) const currentEditingKey = ref('') -const currentEditingValue = ref('') const currentEditingLanguage = ref('json') +const currentEditingTheme = ref('vs-light') +let currentEditingKeyIterable = null -function openEditorDialog(key, value, language) { +function openEditorDialog(key, value, theme, language) { currentEditingKey.value = key - currentEditingValue.value = value currentEditingLanguage.value = language || 'json' + currentEditingTheme.value = theme || 'vs-light' + currentEditingKeyIterable = value dialog.value = true } function saveEditedContent() { - if (currentEditingKey.value && iterable[currentEditingKey.value] !== undefined) { - iterable[currentEditingKey.value] = currentEditingValue.value - } dialog.value = false } @@ -107,7 +106,7 @@ function saveEditedContent() { variant="text" color="primary" class="editor-fullscreen-btn" - @click="openEditorDialog(key, iterable[key], metadata[metadataKey].items[key]?.editor_language)" + @click="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)" title="全屏编辑" > mdi-fullscreen @@ -297,10 +296,10 @@ function saveEditedContent() { diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index a5219457..8bad17e4 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 = { @@ -279,6 +279,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)) { @@ -297,7 +298,7 @@ export default { }; return names[tabType] || tabType; }, - + // 获取提供商简介 getProviderDescription(template, name) { if (name == 'OpenAI') { @@ -305,7 +306,7 @@ export default { } return `${template.type} 服务提供商`; }, - + // 选择提供商模板 selectProviderTemplate(name) { this.newSelectedProviderName = name; @@ -335,7 +336,7 @@ export default { break; } } - + const mergeConfigWithOrder = (target, source, reference) => { // 首先复制所有source中的属性到target if (source && typeof source === 'object' && !Array.isArray(source)) { @@ -349,7 +350,7 @@ export default { } } } - + // 然后根据reference的结构添加或覆盖属性 for (let key in reference) { if (typeof reference[key] === 'object' && reference[key] !== null) { @@ -357,8 +358,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)) { @@ -367,7 +368,7 @@ export default { } } }; - + if (defaultConfig) { mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig); } @@ -418,7 +419,7 @@ export default { providerStatusChange(provider) { provider.enable = !provider.enable; // 切换状态 - + axios.post('/api/config/provider/update', { id: provider.id, config: provider @@ -430,13 +431,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"; @@ -476,4 +477,4 @@ export default { .v-window { border-radius: 4px; } - \ No newline at end of file +