Skip to content

feat: add MiniMax voice cloning to TTS - #9482

Open
octo-patch wants to merge 1 commit into
AstrBotDevs:masterfrom
octo-patch:octo/20260731-voice-clone-tool-recvqKYQTF0k14
Open

feat: add MiniMax voice cloning to TTS#9482
octo-patch wants to merge 1 commit into
AstrBotDevs:masterfrom
octo-patch:octo/20260731-voice-clone-tool-recvqKYQTF0k14

Conversation

@octo-patch

@octo-patch octo-patch commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Reason: MiniMax TTS lacks the configured voice cloning workflow for creating custom voice IDs from reference audio.

This change:

  • uploads configured MP3, M4A, or WAV reference audio with the voice_clone purpose;
  • creates the custom voice through the regional MiniMax voice-cloning endpoint;
  • reuses the created voice ID in the existing TTS request and prevents duplicate concurrent setup calls;
  • adds configuration defaults, schema metadata, and dashboard translations; and
  • adds focused coverage for upload, regional endpoints, validation, caching, and API errors.

Checks:

  • uv run pytest -q tests/test_minimax_tts_api_source.py (6 passed)
  • uv run ruff check .
  • uv run ruff format --check .
  • Python compilation and dashboard metadata JSON validation
  • git diff --check
  • Required secret scan

Summary by Sourcery

Integrate configurable MiniMax voice cloning into the TTS provider and ensure it runs once before synthesis using the cloned voice ID.

New Features:

  • Add support for MiniMax voice cloning using local reference audio to create and reuse custom voice IDs in TTS requests.
  • Expose configuration options for voice clone audio path, custom voice ID, clone model, and regional API base in provider defaults and dashboard metadata.

Enhancements:

  • Guard MiniMax TTS voice cloning with validation, error handling, and single-flight concurrency control to prevent invalid setups and duplicate cloning requests.

Documentation:

  • Update dashboard configuration metadata and translations to describe MiniMax voice cloning options and usage.

Tests:

  • Add focused async tests covering MiniMax voice cloning upload flow, regional API base handling, configuration and audio validation, single-flight behavior, and API error propagation.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Jul 31, 2026
@dosubot

dosubot Bot commented Jul 31, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-08-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about AstrBot Add Dosu to your team

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="tests/test_minimax_tts_api_source.py" line_range="153-105" />
<code_context>
+
+
+@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
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a test for `clone_voice` when no audio path is configured

The existing test only covers concurrent cloning when an audio path is configured. There’s also the case where `minimax-voice-clone-audio` is unset and `clone_voice` is expected to raise `ValueError` (since `_ensure_voice_clone` no-ops). Please add a test that constructs a provider without `minimax-voice-clone-audio` and asserts that `clone_voice()` raises the correct `ValueError` and message.

Suggested implementation:

```python
    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_without_audio_path_raises_value_error():
    # Construct a provider without the `minimax-voice-clone-audio` setting.
    # This relies on `_make_provider` not configuring the audio path when given `None`.
    provider = _make_provider(audio_path=None)

    with pytest.raises(ValueError) as exc_info:
        await provider.clone_voice()

    # The error message should make it clear that `minimax-voice-clone-audio`
    # needs to be configured for `clone_voice` to work.
    assert "minimax-voice-clone-audio" in str(exc_info.value)


@pytest.mark.asyncio

import pytest

from astrbot.core.provider.sources.minimax_tts_api_source import (
    ProviderMiniMaxTTSAPI,
)


class _FakeResponse:

```

Depending on how `_make_provider` is currently implemented, you may need to:
1. Update `_make_provider` to treat `audio_path=None` as “no audio configured” and avoid setting the `minimax-voice-clone-audio` key in the provider configuration when `audio_path` is `None`.
2. If `_make_provider` cannot accept `None`, instead construct the provider explicitly in the new test (e.g. `ProviderMiniMaxTTSAPI(config=...)`) using the same required config keys as other tests, but omitting `minimax-voice-clone-audio`.
3. If the `ValueError` message is more specific (e.g. `"minimax-voice-clone-audio must be configured"`), you can tighten the assertion to match the exact message rather than just checking that it contains `minimax-voice-clone-audio`.
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/minimax_tts_api_source.py" line_range="113" />
<code_context>
+                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:
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the new voice-cloning logic into smaller helper methods and precomputed URL state to simplify `_ensure_voice_clone` and related flows without changing behavior.

You can reduce the complexity notably without changing behavior by extracting a few focused helpers and moving some normalization to initialization. For example:

### 1. Split `_ensure_voice_clone` responsibilities

Right now `_ensure_voice_clone` mixes configuration validation, filesystem, HTTP, response parsing, and locking. You can keep locking + state in `_ensure_voice_clone` and move the rest into helpers:

```python
async def _ensure_voice_clone(self) -> None:
    if not self.voice_clone_audio or self._voice_clone_ready:
        return

    async with self._voice_clone_lock:
        if self._voice_clone_ready:
            return

        self._validate_voice_clone_config()
        audio_path, content_type = self._prepare_audio_file()

        try:
            async with aiohttp.ClientSession() as session:
                file_id = await self._upload_voice_clone_audio(session, audio_path, content_type)
                voice_id = await self._create_voice_clone(session, file_id)
        except aiohttp.ClientError as exc:
            raise RuntimeError(f"MiniMax voice cloning request failed: {exc!s}") from exc

        self.voice_setting["voice_id"] = voice_id
        self._voice_clone_ready = True
```

Then implement small helpers with the current logic moved out:

```python
def _validate_voice_clone_config(self) -> None:
    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.")

def _prepare_audio_file(self) -> tuple[Path, str]:
    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.",
        )

    return audio_path, content_type
```

```python
async def _upload_voice_clone_audio(
    self,
    session: aiohttp.ClientSession,
    audio_path: Path,
    content_type: str,
) -> str:
    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:
            await self._ensure_ok_response(response, "MiniMax voice-clone audio upload")
            upload_data = await response.json(content_type=None)

    self._ensure_base_resp_ok(upload_data, "MiniMax voice-clone audio upload")

    file_id = self._extract_file_id(upload_data)
    if not file_id:
        raise RuntimeError(
            "MiniMax voice-clone audio upload returned no file_id.",
        )
    return file_id
```

```python
async def _create_voice_clone(
    self,
    session: aiohttp.ClientSession,
    file_id: str,
) -> str:
    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:
        await self._ensure_ok_response(response, "MiniMax voice cloning")
        clone_data = await response.json(content_type=None)

    self._ensure_base_resp_ok(clone_data, "MiniMax voice cloning")
    return self._extract_voice_id(clone_data)
```

### 2. Centralize response status / base_resp handling

You repeat HTTP status checks and `base_resp` parsing. Centralizing them keeps the main flow linear:

```python
async def _ensure_ok_response(
    self,
    response: aiohttp.ClientResponse,
    context: str,
) -> None:
    if response.status >= 400:
        error_text = (await response.text())[:1024]
        raise RuntimeError(
            f"{context} failed: HTTP {response.status}: {error_text}",
        )

def _ensure_base_resp_ok(self, data: dict, context: str) -> None:
    base_resp = data.get("base_resp", {}) or {}
    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"{context} failed: {status_msg}")
```

And small extractors for the different response shapes:

```python
def _extract_file_id(self, data: dict) -> str | None:
    file_data = data.get("file") or {}
    nested_data = data.get("data") or {}
    return (
        data.get("file_id")
        or file_data.get("file_id")
        or nested_data.get("file_id")
    )

def _extract_voice_id(self, data: dict) -> str:
    clone_result = data.get("data") or {}
    return (
        data.get("voice_id")
        or clone_result.get("voice_id")
        or self.voice_clone_id
    )
```

### 3. Normalize URL roots in `__init__` instead of per call

You can precompute the voice-clone root, which makes `_voice_clone_url` trivial and easier to reason about:

```python
def __init__(...):
    ...
    api_base_raw: str = provider_config.get(
        "api_base",
        "https://api.minimax.chat/v1/t2a_v2",
    )
    self.api_base = api_base_raw

    voice_clone_api_base_raw = str(
        provider_config.get("minimax-voice-clone-api-base") or "",
    ).strip()

    root = voice_clone_api_base_raw or api_base_raw
    root = root.rstrip("/")

    # Strip t2a path if present; keep `/v1` root.
    if root.endswith("/t2a_v2"):
        root = root.rsplit("/", 1)[0]
    self._voice_clone_root = root if root.endswith("/v1") else root.rsplit("/", 1)[0]
    ...
```

Then:

```python
def _voice_clone_url(self, path: str) -> str:
    return f"{self._voice_clone_root.rstrip('/')}/{path.lstrip('/')}"
```

These changes keep all functionality intact but make the voice-cloning flow much easier to follow, test, and maintain by separating concerns and removing nested control flow from the “hot” methods.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

]
== "created-voice"
)
assert len(session.posts) == 2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for clone_voice when no audio path is configured

The existing test only covers concurrent cloning when an audio path is configured. There’s also the case where minimax-voice-clone-audio is unset and clone_voice is expected to raise ValueError (since _ensure_voice_clone no-ops). Please add a test that constructs a provider without minimax-voice-clone-audio and asserts that clone_voice() raises the correct ValueError and message.

Suggested implementation:

    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_without_audio_path_raises_value_error():
    # Construct a provider without the `minimax-voice-clone-audio` setting.
    # This relies on `_make_provider` not configuring the audio path when given `None`.
    provider = _make_provider(audio_path=None)

    with pytest.raises(ValueError) as exc_info:
        await provider.clone_voice()

    # The error message should make it clear that `minimax-voice-clone-audio`
    # needs to be configured for `clone_voice` to work.
    assert "minimax-voice-clone-audio" in str(exc_info.value)


@pytest.mark.asyncio

import pytest

from astrbot.core.provider.sources.minimax_tts_api_source import (
    ProviderMiniMaxTTSAPI,
)


class _FakeResponse:

Depending on how _make_provider is currently implemented, you may need to:

  1. Update _make_provider to treat audio_path=None as “no audio configured” and avoid setting the minimax-voice-clone-audio key in the provider configuration when audio_path is None.
  2. If _make_provider cannot accept None, instead construct the provider explicitly in the new test (e.g. ProviderMiniMaxTTSAPI(config=...)) using the same required config keys as other tests, but omitting minimax-voice-clone-audio.
  3. If the ValueError message is more specific (e.g. "minimax-voice-clone-audio must be configured"), you can tighten the assertion to match the exact message rather than just checking that it contains minimax-voice-clone-audio.

api_base = api_base.rsplit("/", 1)[0]
return f"{api_base.rstrip('/')}/{path.lstrip('/')}"

async def _ensure_voice_clone(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the new voice-cloning logic into smaller helper methods and precomputed URL state to simplify _ensure_voice_clone and related flows without changing behavior.

You can reduce the complexity notably without changing behavior by extracting a few focused helpers and moving some normalization to initialization. For example:

1. Split _ensure_voice_clone responsibilities

Right now _ensure_voice_clone mixes configuration validation, filesystem, HTTP, response parsing, and locking. You can keep locking + state in _ensure_voice_clone and move the rest into helpers:

async def _ensure_voice_clone(self) -> None:
    if not self.voice_clone_audio or self._voice_clone_ready:
        return

    async with self._voice_clone_lock:
        if self._voice_clone_ready:
            return

        self._validate_voice_clone_config()
        audio_path, content_type = self._prepare_audio_file()

        try:
            async with aiohttp.ClientSession() as session:
                file_id = await self._upload_voice_clone_audio(session, audio_path, content_type)
                voice_id = await self._create_voice_clone(session, file_id)
        except aiohttp.ClientError as exc:
            raise RuntimeError(f"MiniMax voice cloning request failed: {exc!s}") from exc

        self.voice_setting["voice_id"] = voice_id
        self._voice_clone_ready = True

Then implement small helpers with the current logic moved out:

def _validate_voice_clone_config(self) -> None:
    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.")

def _prepare_audio_file(self) -> tuple[Path, str]:
    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.",
        )

    return audio_path, content_type
async def _upload_voice_clone_audio(
    self,
    session: aiohttp.ClientSession,
    audio_path: Path,
    content_type: str,
) -> str:
    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:
            await self._ensure_ok_response(response, "MiniMax voice-clone audio upload")
            upload_data = await response.json(content_type=None)

    self._ensure_base_resp_ok(upload_data, "MiniMax voice-clone audio upload")

    file_id = self._extract_file_id(upload_data)
    if not file_id:
        raise RuntimeError(
            "MiniMax voice-clone audio upload returned no file_id.",
        )
    return file_id
async def _create_voice_clone(
    self,
    session: aiohttp.ClientSession,
    file_id: str,
) -> str:
    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:
        await self._ensure_ok_response(response, "MiniMax voice cloning")
        clone_data = await response.json(content_type=None)

    self._ensure_base_resp_ok(clone_data, "MiniMax voice cloning")
    return self._extract_voice_id(clone_data)

2. Centralize response status / base_resp handling

You repeat HTTP status checks and base_resp parsing. Centralizing them keeps the main flow linear:

async def _ensure_ok_response(
    self,
    response: aiohttp.ClientResponse,
    context: str,
) -> None:
    if response.status >= 400:
        error_text = (await response.text())[:1024]
        raise RuntimeError(
            f"{context} failed: HTTP {response.status}: {error_text}",
        )

def _ensure_base_resp_ok(self, data: dict, context: str) -> None:
    base_resp = data.get("base_resp", {}) or {}
    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"{context} failed: {status_msg}")

And small extractors for the different response shapes:

def _extract_file_id(self, data: dict) -> str | None:
    file_data = data.get("file") or {}
    nested_data = data.get("data") or {}
    return (
        data.get("file_id")
        or file_data.get("file_id")
        or nested_data.get("file_id")
    )

def _extract_voice_id(self, data: dict) -> str:
    clone_result = data.get("data") or {}
    return (
        data.get("voice_id")
        or clone_result.get("voice_id")
        or self.voice_clone_id
    )

3. Normalize URL roots in __init__ instead of per call

You can precompute the voice-clone root, which makes _voice_clone_url trivial and easier to reason about:

def __init__(...):
    ...
    api_base_raw: str = provider_config.get(
        "api_base",
        "https://api.minimax.chat/v1/t2a_v2",
    )
    self.api_base = api_base_raw

    voice_clone_api_base_raw = str(
        provider_config.get("minimax-voice-clone-api-base") or "",
    ).strip()

    root = voice_clone_api_base_raw or api_base_raw
    root = root.rstrip("/")

    # Strip t2a path if present; keep `/v1` root.
    if root.endswith("/t2a_v2"):
        root = root.rsplit("/", 1)[0]
    self._voice_clone_root = root if root.endswith("/v1") else root.rsplit("/", 1)[0]
    ...

Then:

def _voice_clone_url(self, path: str) -> str:
    return f"{self._voice_clone_root.rstrip('/')}/{path.lstrip('/')}"

These changes keep all functionality intact but make the voice-cloning flow much easier to follow, test, and maintain by separating concerns and removing nested control flow from the “hot” methods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant