diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index eac4bafc01..ccf57127fd 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1770,6 +1770,10 @@ "api_base": "https://api.minimax.chat/v1/t2a_v2", "minimax-group-id": "", "model": "speech-02-turbo", + "minimax-voice-clone-audio": "", + "minimax-voice-clone-id": "", + "minimax-voice-clone-model": "speech-2.8-hd", + "minimax-voice-clone-api-base": "https://api.minimaxi.com/v1", "minimax-langboost": "auto", "minimax-voice-speed": 1.0, "minimax-voice-vol": 1.0, @@ -2566,6 +2570,36 @@ "description": "单一音色", "hint": "单一音色编号, 详见官网文档", }, + "minimax-voice-clone-audio": { + "type": "string", + "description": "Voice clone audio path", + "hint": "Local .mp3, .m4a, or .wav reference audio. Leave empty to disable automatic voice cloning.", + }, + "minimax-voice-clone-id": { + "type": "string", + "description": "Voice clone ID", + "hint": "Custom voice ID to create before synthesis. Required when voice clone audio is configured.", + }, + "minimax-voice-clone-model": { + "type": "string", + "description": "Voice clone model", + "options": [ + "speech-2.8-hd", + "speech-2.6-hd", + "speech-02-hd", + "speech-01-hd", + ], + "hint": "HD speech model used to create the custom voice.", + }, + "minimax-voice-clone-api-base": { + "type": "string", + "description": "Voice clone API base", + "options": [ + "https://api.minimax.io/v1", + "https://api.minimaxi.com/v1", + ], + "hint": "Regional MiniMax API base for file upload and voice cloning.", + }, "minimax-voice-emotion": { "type": "string", "description": "情绪", diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index 97d746c557..309bbee782 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -1,7 +1,9 @@ +import asyncio import json import os import uuid from collections.abc import AsyncIterator +from pathlib import Path import aiohttp @@ -32,6 +34,20 @@ def __init__( ) self.group_id: str = provider_config.get("minimax-group-id", "") self.set_model(provider_config.get("model", "")) + self.voice_clone_audio: str = str( + provider_config.get("minimax-voice-clone-audio") or "", + ).strip() + self.voice_clone_id: str = str( + provider_config.get("minimax-voice-clone-id") or "", + ).strip() + self.voice_clone_model: str = str( + provider_config.get("minimax-voice-clone-model") or "", + ).strip() + self.voice_clone_api_base: str = str( + provider_config.get("minimax-voice-clone-api-base") or "", + ).strip() + self._voice_clone_ready = False + self._voice_clone_lock = asyncio.Lock() self.lang_boost: str = provider_config.get("minimax-langboost", "auto") self.is_timber_weight: bool = provider_config.get( "minimax-is-timber-weight", @@ -84,6 +100,155 @@ def __init__( "content-type": "application/json", } + def _voice_clone_url(self, path: str) -> str: + """Build a MiniMax voice-cloning endpoint URL.""" + if self.voice_clone_api_base: + api_base = self.voice_clone_api_base + else: + api_base = self.api_base.rstrip("/") + if not api_base.endswith("/v1"): + api_base = api_base.rsplit("/", 1)[0] + return f"{api_base.rstrip('/')}/{path.lstrip('/')}" + + async def _ensure_voice_clone(self) -> None: + """Create the configured custom voice before the first synthesis request.""" + if not self.voice_clone_audio: + return + if self._voice_clone_ready: + return + + async with self._voice_clone_lock: + if self._voice_clone_ready: + return + if not self.voice_clone_id: + raise ValueError( + "MiniMax voice cloning requires 'minimax-voice-clone-id'.", + ) + if not self.voice_clone_model: + raise ValueError( + "MiniMax voice cloning requires 'minimax-voice-clone-model'.", + ) + if not self.voice_clone_model.endswith("-hd"): + raise ValueError( + "MiniMax voice cloning requires an HD speech model.", + ) + if self.is_timber_weight: + raise ValueError( + "MiniMax voice cloning cannot be combined with mixed voices.", + ) + + audio_path = Path(self.voice_clone_audio).expanduser() + if not audio_path.is_file(): + raise FileNotFoundError( + f"MiniMax voice-clone audio file does not exist: {audio_path}", + ) + + content_type = { + ".m4a": "audio/mp4", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + }.get(audio_path.suffix.lower()) + if content_type is None: + raise ValueError( + "MiniMax voice cloning supports only .mp3, .m4a, and .wav files.", + ) + + try: + async with aiohttp.ClientSession() as session: + form = aiohttp.FormData() + with audio_path.open("rb") as audio_file: + form.add_field( + "file", + audio_file, + filename=audio_path.name, + content_type=content_type, + ) + form.add_field("purpose", "voice_clone") + async with session.post( + self._voice_clone_url("files/upload"), + headers={"Authorization": self.headers["Authorization"]}, + data=form, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status >= 400: + error_text = (await response.text())[:1024] + raise RuntimeError( + "MiniMax voice-clone audio upload failed: " + f"HTTP {response.status}: {error_text}", + ) + upload_data = await response.json(content_type=None) + + upload_base_resp = upload_data.get("base_resp", {}) + upload_status_code = upload_base_resp.get("status_code") + if upload_status_code not in (None, 0, "0"): + status_msg = upload_base_resp.get( + "status_msg", + "unknown error", + ) + raise RuntimeError( + f"MiniMax voice-clone audio upload failed: {status_msg}", + ) + + file_data = upload_data.get("file") or {} + nested_data = upload_data.get("data") or {} + file_id = ( + upload_data.get("file_id") + or file_data.get("file_id") + or nested_data.get("file_id") + ) + if not file_id: + raise RuntimeError( + "MiniMax voice-clone audio upload returned no file_id.", + ) + + async with session.post( + self._voice_clone_url("voice_clone"), + headers=self.headers, + json={ + "file_id": file_id, + "voice_id": self.voice_clone_id, + "model": self.voice_clone_model, + }, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status >= 400: + error_text = (await response.text())[:1024] + raise RuntimeError( + "MiniMax voice cloning failed: " + f"HTTP {response.status}: {error_text}", + ) + clone_data = await response.json(content_type=None) + + base_resp = clone_data.get("base_resp", {}) + status_code = base_resp.get("status_code") + if status_code not in (None, 0, "0"): + status_msg = base_resp.get("status_msg", "unknown error") + raise RuntimeError( + f"MiniMax voice cloning failed: {status_msg}", + ) + + clone_result = clone_data.get("data") or {} + voice_id = ( + clone_data.get("voice_id") + or clone_result.get("voice_id") + or self.voice_clone_id + ) + self.voice_setting["voice_id"] = voice_id + self._voice_clone_ready = True + except aiohttp.ClientError as exc: + raise RuntimeError( + f"MiniMax voice cloning request failed: {exc!s}", + ) from exc + + async def clone_voice(self) -> str: + """Create and return the configured MiniMax custom voice ID.""" + await self._ensure_voice_clone() + if not self.voice_clone_audio: + raise ValueError( + "MiniMax voice cloning requires 'minimax-voice-clone-audio'.", + ) + return self.voice_setting["voice_id"] + def _build_tts_stream_body(self, text: str): """构建流式请求体""" dict_body: dict[str, object] = { @@ -154,6 +319,7 @@ async def _audio_play(self, audio_stream: AsyncIterator[str]) -> bytes: return b"".join(chunks) async def get_audio(self, text: str) -> str: + await self._ensure_voice_clone() temp_dir = get_astrbot_temp_path() os.makedirs(temp_dir, exist_ok=True) path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.wav") diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index fdd430c63e..72b27eafc9 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1564,6 +1564,22 @@ "description": "Single voice", "hint": "Single voice ID; see the official documentation." }, + "minimax-voice-clone-audio": { + "description": "Voice clone audio path", + "hint": "Local .mp3, .m4a, or .wav reference audio. Leave empty to disable automatic voice cloning." + }, + "minimax-voice-clone-id": { + "description": "Voice clone ID", + "hint": "Custom voice ID to create before synthesis. Required when voice clone audio is configured." + }, + "minimax-voice-clone-model": { + "description": "Voice clone model", + "hint": "HD speech model used to create the custom voice." + }, + "minimax-voice-clone-api-base": { + "description": "Voice clone API base", + "hint": "Regional MiniMax API base for file upload and voice cloning." + }, "minimax-voice-emotion": { "description": "Emotion", "hint": "Controls emotion of synthesized speech. When set to auto, it selects emotion based on text." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 3cd0104ca1..fca8d0b852 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1561,6 +1561,22 @@ "description": "Одиночный голос", "hint": "ID одиночного голоса; см. официальную документацию." }, + "minimax-voice-clone-audio": { + "description": "Voice clone audio path", + "hint": "Local .mp3, .m4a, or .wav reference audio. Leave empty to disable automatic voice cloning." + }, + "minimax-voice-clone-id": { + "description": "Voice clone ID", + "hint": "Custom voice ID to create before synthesis. Required when voice clone audio is configured." + }, + "minimax-voice-clone-model": { + "description": "Voice clone model", + "hint": "HD speech model used to create the custom voice." + }, + "minimax-voice-clone-api-base": { + "description": "Voice clone API base", + "hint": "Regional MiniMax API base for file upload and voice cloning." + }, "minimax-voice-emotion": { "description": "Эмоция", "hint": "Эмоция речи. 'auto' выбирает эмоцию на основе текста." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index e25cb8e0fb..46058f596f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1566,6 +1566,22 @@ "description": "单一音色", "hint": "单一音色编号, 详见官网文档" }, + "minimax-voice-clone-audio": { + "description": "Voice clone audio path", + "hint": "Local .mp3, .m4a, or .wav reference audio. Leave empty to disable automatic voice cloning." + }, + "minimax-voice-clone-id": { + "description": "Voice clone ID", + "hint": "Custom voice ID to create before synthesis. Required when voice clone audio is configured." + }, + "minimax-voice-clone-model": { + "description": "Voice clone model", + "hint": "HD speech model used to create the custom voice." + }, + "minimax-voice-clone-api-base": { + "description": "Voice clone API base", + "hint": "Regional MiniMax API base for file upload and voice cloning." + }, "minimax-voice-emotion": { "description": "情绪", "hint": "控制合成语音的情绪。当为 auto 时,将根据文本内容自动选择情绪。" diff --git a/tests/test_minimax_tts_api_source.py b/tests/test_minimax_tts_api_source.py new file mode 100644 index 0000000000..aa66d53782 --- /dev/null +++ b/tests/test_minimax_tts_api_source.py @@ -0,0 +1,205 @@ +import asyncio +import json +from pathlib import Path + +import pytest + +from astrbot.core.provider.sources.minimax_tts_api_source import ( + ProviderMiniMaxTTSAPI, +) + + +class _FakeResponse: + def __init__(self, payload: dict, status: int = 200) -> None: + self.payload = payload + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args) -> None: + return None + + async def json(self, **_kwargs) -> dict: + return self.payload + + async def text(self) -> str: + return json.dumps(self.payload) + + +class _FakeSession: + def __init__(self, responses: list[_FakeResponse]) -> None: + self.responses = responses + self.posts: list[tuple[str, dict]] = [] + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args) -> None: + return None + + def post(self, url: str, **kwargs): + self.posts.append((url, kwargs)) + return self.responses.pop(0) + + +class _FakeFormData: + last: "_FakeFormData | None" = None + + def __init__(self) -> None: + self.fields: list[tuple[str, object, dict]] = [] + self.__class__.last = self + + def add_field(self, name: str, value: object, **kwargs) -> None: + self.fields.append((name, value, kwargs)) + + +def _make_provider(audio_path: Path, **overrides) -> ProviderMiniMaxTTSAPI: + config = { + "id": "test-minimax-tts", + "type": "minimax_tts_api", + "api_key": "test-key", + "api_base": "https://api.minimaxi.com/v1/t2a_v2", + "model": "speech-02-turbo", + "minimax-voice-id": "default_voice", + "minimax-voice-clone-audio": str(audio_path), + "minimax-voice-clone-id": "custom_voice", + "minimax-voice-clone-model": "speech-2.8-hd", + "minimax-voice-clone-api-base": "https://api.minimaxi.com/v1", + } + config.update(overrides) + return ProviderMiniMaxTTSAPI(config, {}) + + +@pytest.mark.asyncio +async def test_clone_voice_uploads_audio_and_uses_returned_voice_id( + monkeypatch, + tmp_path, +): + audio_path = tmp_path / "reference.wav" + audio_path.write_bytes(b"wav data") + provider = _make_provider(audio_path) + session = _FakeSession( + [ + _FakeResponse({"file": {"file_id": "file-123"}}), + _FakeResponse({"data": {"voice_id": "created-voice"}}), + ] + ) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.ClientSession", + lambda: session, + ) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.FormData", + _FakeFormData, + ) + + assert await provider.clone_voice() == "created-voice" + assert provider.voice_setting["voice_id"] == "created-voice" + assert ( + json.loads(provider._build_tts_stream_body("hello"))["voice_setting"][ + "voice_id" + ] + == "created-voice" + ) + assert len(session.posts) == 2 + assert session.posts[0][0] == "https://api.minimaxi.com/v1/files/upload" + assert session.posts[0][1]["headers"] == {"Authorization": "Bearer test-key"} + assert session.posts[1][0] == "https://api.minimaxi.com/v1/voice_clone" + assert session.posts[1][1]["json"] == { + "file_id": "file-123", + "voice_id": "custom_voice", + "model": "speech-2.8-hd", + } + assert _FakeFormData.last is not None + assert [field[0] for field in _FakeFormData.last.fields] == ["file", "purpose"] + assert _FakeFormData.last.fields[1][1] == "voice_clone" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "api_base", + ["https://api.minimax.io/v1", "https://api.minimaxi.com/v1"], +) +async def test_clone_voice_supports_both_regional_api_bases( + monkeypatch, + tmp_path, + api_base, +): + audio_path = tmp_path / "reference.mp3" + audio_path.write_bytes(b"mp3 data") + provider = _make_provider( + audio_path, + **{"minimax-voice-clone-api-base": api_base}, + ) + session = _FakeSession([_FakeResponse({"file_id": "file-123"}), _FakeResponse({})]) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.ClientSession", + lambda: session, + ) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.FormData", + _FakeFormData, + ) + + assert await provider.clone_voice() == "custom_voice" + assert [post[0] for post in session.posts] == [ + f"{api_base}/files/upload", + f"{api_base}/voice_clone", + ] + + +@pytest.mark.asyncio +async def test_clone_voice_is_single_flight(monkeypatch, tmp_path): + audio_path = tmp_path / "reference.wav" + audio_path.write_bytes(b"wav data") + provider = _make_provider(audio_path) + session = _FakeSession([_FakeResponse({"file_id": "file-123"}), _FakeResponse({})]) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.ClientSession", + lambda: session, + ) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.FormData", + _FakeFormData, + ) + + assert await asyncio.gather(provider.clone_voice(), provider.clone_voice()) == [ + "custom_voice", + "custom_voice", + ] + assert len(session.posts) == 2 + + +@pytest.mark.asyncio +async def test_clone_voice_validates_configuration_and_audio_format(tmp_path): + audio_path = tmp_path / "reference.txt" + audio_path.write_text("not audio") + provider = _make_provider(audio_path, **{"minimax-voice-clone-id": ""}) + with pytest.raises(ValueError, match="minimax-voice-clone-id"): + await provider.clone_voice() + + provider = _make_provider(audio_path) + with pytest.raises(ValueError, match="only \\.mp3, \\.m4a, and \\.wav"): + await provider.clone_voice() + + +@pytest.mark.asyncio +async def test_clone_voice_propagates_api_status_error(monkeypatch, tmp_path): + audio_path = tmp_path / "reference.wav" + audio_path.write_bytes(b"wav data") + provider = _make_provider(audio_path) + session = _FakeSession( + [_FakeResponse({"base_resp": {"status_code": 1004, "status_msg": "bad audio"}})] + ) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.ClientSession", + lambda: session, + ) + monkeypatch.setattr( + "astrbot.core.provider.sources.minimax_tts_api_source.aiohttp.FormData", + _FakeFormData, + ) + + with pytest.raises(RuntimeError, match="bad audio"): + await provider.clone_voice()