Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ def is_loaded(self) -> bool:
"chatterbox_turbo": "Chatterbox Turbo",
"tada": "TADA",
"kokoro": "Kokoro",
"minimax": "MiniMax TTS",
}

LLM_ENGINES = {
Expand Down Expand Up @@ -553,6 +554,13 @@ async def ensure_model_cached_or_raise(engine: str, model_size: str = "default")
status_code=400,
detail=f"Model {model_size} is not downloaded yet. Use /generate to trigger a download.",
)
elif engine == "minimax":
# MiniMax is a cloud API — "cached" means the API key is configured.
if not backend._is_model_cached():
raise HTTPException(
status_code=400,
detail="MINIMAX_API_KEY is not configured. Set it in your environment or ~/.env.local.",
)
else:
if not backend._is_model_cached():
display = cfg.display_name if cfg else engine
Expand Down Expand Up @@ -723,6 +731,10 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
from .qwen_custom_voice_backend import QwenCustomVoiceBackend

backend = QwenCustomVoiceBackend()
elif engine == "minimax":
from .minimax_backend import MiniMaxTTSBackend

backend = MiniMaxTTSBackend()
else:
raise ValueError(f"Unknown TTS engine: {engine}. Supported: {list(TTS_ENGINES.keys())}")

Expand Down
348 changes: 348 additions & 0 deletions backend/backends/minimax_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
"""
MiniMax cloud TTS backend implementation.

Unlike the local engines, this backend does not download a model — it calls
the MiniMax Text-to-Audio (T2A) HTTP API and decodes the returned audio to a
numpy float32 array. It plugs into the same ``TTSBackend`` protocol as the
local engines, so long-text chunking, trimming and profile handling all work
unchanged.

Regions:
- ``global_en`` (default): https://api.minimax.io/v1/t2a_v2
- ``cn_zh``: https://api.minimaxi.com/v1/t2a_v2
Select with the ``MINIMAX_API_REGION`` environment variable.

Speech models (``MINIMAX_TTS_MODELS``) all share the same request shape; the
default is ``speech-2.8-hd``. Audio is requested as hex-encoded data in the
synchronous response (``data.audio``) and decoded locally — ``pcm`` is decoded
directly to float32 with no codec dependency, while ``mp3`` / ``wav`` / ``flac``
are decoded through soundfile.

Voices are preset MiniMax system voices. Voice cloning is intentionally out of
scope for this backend.

Requirements:
- ``MINIMAX_API_KEY`` environment variable (or ``~/.env.local``)
- httpx (already in requirements.txt)
"""

from __future__ import annotations

import asyncio
import io
import logging
import os

import numpy as np

from .base import combine_voice_prompts as _combine_voice_prompts

logger = logging.getLogger(__name__)

# Regional API endpoints — the global and mainland-China hosts expose the same
# request/response shape and differ only in the base host.
MINIMAX_ENDPOINTS: dict[str, str] = {
"global_en": "https://api.minimax.io/v1/t2a_v2",
"cn_zh": "https://api.minimaxi.com/v1/t2a_v2",
}
MINIMAX_DEFAULT_REGION = "global_en"

# Speech models, newest first. All use the same T2A request shape.
MINIMAX_TTS_MODELS = [
"speech-2.8-hd",
"speech-2.8-turbo",
"speech-2.6-hd",
"speech-2.6-turbo",
"speech-02-hd",
"speech-02-turbo",
"speech-01-hd",
"speech-01-turbo",
]
MINIMAX_TTS_DEFAULT_MODEL = "speech-2.8-hd"

# Supported synchronous-response audio formats.
MINIMAX_AUDIO_FORMATS = ["mp3", "wav", "flac", "pcm"]
MINIMAX_DEFAULT_FORMAT = "pcm"

# 32 kHz mono is a good default for speech and is supported by every model.
MINIMAX_DEFAULT_SAMPLE_RATE = 32000

MINIMAX_DEFAULT_VOICE = "English_Graceful_Lady"

# Preset system voices: (voice_id, display_name, gender, language).
MINIMAX_VOICES: list[tuple[str, str, str, str]] = [
("English_Graceful_Lady", "Graceful Lady", "female", "en"),
("English_radiant_girl", "Radiant Girl", "female", "en"),
("English_expressive_narrator", "Expressive Narrator", "female", "en"),
("English_Insightful_Speaker", "Insightful Speaker", "male", "en"),
("English_Persuasive_Man", "Persuasive Man", "male", "en"),
("English_Lucky_Robot", "Lucky Robot", "male", "en"),
("Chinese_Gentle_and_Clear", "Gentle and Clear", "female", "zh"),
("Chinese_Elegant_Lady", "Elegant Lady", "female", "zh"),
("Chinese_Intellectual_Female", "Intellectual Female", "female", "zh"),
("Chinese_Energetic_Boy", "Energetic Boy", "male", "zh"),
("Chinese_Magnetic_Male", "Magnetic Male", "male", "zh"),
]

# Map our ISO language codes to MiniMax ``language_boost`` names. Unmapped
# languages fall back to "auto" so MiniMax detects the language itself.
LANGUAGE_BOOST_MAP = {
"en": "English",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
"de": "German",
"fr": "French",
"ru": "Russian",
"pt": "Portuguese",
"es": "Spanish",
"it": "Italian",
}
Comment on lines +107 to +118

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'minimax_backend\.py|models\.py$' . | sed 's#^\./##'

echo "== inspect minimax backend relevant section =="
if [ -f backend/backends/minimax_backend.py ]; then
  wc -l backend/backends/minimax_backend.py
  sed -n '1,180p' backend/backends/minimax_backend.py | cat -n
fi

echo "== search request language type/constants =="
rg -n "GenerationRequest|language|LANGUAGE_BOOST_MAP|boost" backend -S || true

Repository: jamiepine/voicebox

Length of output: 24857


🌐 Web query:

MiniMax HTTP Speech T2A supported languages boost API language_code

💡 Result:

MiniMax text-to-audio (T2A) models support 40 widely used global languages [1][2][3]. The language_boost parameter in the API is used to enhance recognition or synthesis accuracy for these languages, allowing you to specify a language name or set it to "Automatic" [4]. For certain languages with specific dialectal needs, such as Cantonese, the parameter may require a combined string like "Chinese,Yue" [5][6]. The 40 supported languages include: Chinese (including Cantonese), English, Spanish, French, Russian, German, Portuguese, Arabic, Italian, Japanese, Korean, Indonesian, Vietnamese, Turkish, Dutch, Ukrainian, Thai, Polish, Romanian, Greek, Czech, Finnish, Hindi, Bulgarian, Danish, Hebrew, Malay, Persian, Slovak, Swedish, Croatian, Filipino, Hungarian, Norwegian, Slovenian, Catalan, Nynorsk, Tamil, and Afrikaans [1][2][3]. Note that certain languages, such as Filipino, Tamil, and Persian, are specifically supported by the newer speech-2.6 series models and later [7][8]. When using the API, ensure you select the appropriate model (e.g., speech-2.8-hd, speech-2.8-turbo, or speech-2.6-*) to access the full range of language capabilities [1][8].

Citations:


🌐 Web query:

MiniMax T2A language_boost exact value Arabic Danish Greek Finnish Hebrew Hind

💡 Result:

In the MiniMax Text-to-Audio (T2A) API, the language_boost parameter is used to specify a language or dialect to enhance recognition and synthesis accuracy, preventing pronunciation errors [1][2][3]. Arabic, Danish, Greek, Finnish, Hebrew, and Hindi are all supported, valid values for this parameter [1][3][4]. When using this parameter, you must provide the language name exactly as defined in the API's supported list [1][5]. The supported values for the requested languages are: - Arabic [1][4] - Danish [1][4] - Greek [1][4] - Finnish [1][4] - Hebrew [1][4] - Hindi [1][4] Other common valid values include 'auto' (for automatic detection), 'English', 'Chinese', 'French', 'German', and many others [1][3][5]. The parameter defaults to null if not specified [1][6]. Note that while the model supports these languages, the speech-01 and speech-02 series models have specific limitations regarding Persian, Filipino, and Tamil, which are not currently supported for enhancement via this parameter [1].

Citations:


🌐 Web query:

site:platform.minimax.io/api-reference/speech-t2a-http T2A language_boost Arabic Hebrew Finnish

💡 Result:

Yes — the MiniMax T2A HTTP docs list Arabic, Hebrew, and Finnish as available language_boost options. The docs also note that language_boost can be set to "auto" if the language is unknown. [1]


Map the remaining explicitly supported request languages.

GenerationRequest.language allows ar, he, da, el, fi, hi, ms, nl, no, pl, sv, and tr, but LANGUAGE_BOOST_MAP sends "auto" for all of them. Add MiniMax-supported boost names for these codes so GenerationRequest.language is preserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/backends/minimax_backend.py` around lines 89 - 100, Extend
LANGUAGE_BOOST_MAP with MiniMax-supported boost names for the explicitly
supported language codes ar, he, da, el, fi, hi, ms, nl, no, pl, sv, and tr,
ensuring GenerationRequest.language values are mapped to their corresponding
language names instead of falling back to "auto".



def resolve_region(region: str | None = None) -> str:
"""Resolve the MiniMax API region, honouring ``MINIMAX_API_REGION``.

Falls back to the global endpoint for unknown or missing values.
"""
candidate = region or os.environ.get("MINIMAX_API_REGION") or MINIMAX_DEFAULT_REGION
candidate = candidate.strip().lower()
if candidate not in MINIMAX_ENDPOINTS:
return MINIMAX_DEFAULT_REGION
return candidate


def get_endpoint(region: str | None = None) -> str:
"""Return the T2A endpoint URL for the resolved region."""
return MINIMAX_ENDPOINTS[resolve_region(region)]


def _load_api_key() -> str | None:
"""Load ``MINIMAX_API_KEY`` from the environment or ``~/.env.local``."""
key = os.environ.get("MINIMAX_API_KEY")
if key:
return key

env_local = os.path.expanduser("~/.env.local")
if os.path.exists(env_local):
try:
with open(env_local) as fh:
for raw_line in fh:
line = raw_line.strip()
if line.startswith("MINIMAX_API_KEY="):
val = line[len("MINIMAX_API_KEY=") :].strip().strip("\"'")
if val:
return val
except OSError:
pass

return None


def _decode_audio(audio_bytes: bytes, audio_format: str, sample_rate: int) -> tuple[np.ndarray, int]:
"""Decode MiniMax audio bytes into a float32 array.

``pcm`` is signed 16-bit little-endian and is decoded directly. Container
formats (mp3/wav/flac) are decoded through soundfile, which reports the
real sample rate from the stream.
"""
if not audio_bytes:
return np.zeros(sample_rate, dtype=np.float32), sample_rate

if audio_format == "pcm":
audio_int16 = np.frombuffer(audio_bytes, dtype="<i2")
return audio_int16.astype(np.float32) / 32768.0, sample_rate

import soundfile as sf

audio, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32", always_2d=False)
if audio.ndim > 1:
audio = audio.mean(axis=1)
return audio.astype(np.float32), int(sr)


class MiniMaxTTSBackend:
"""MiniMax cloud TTS backend — no local model, one HTTP call per request.

The backend is "loaded" whenever the API key is available; ``load_model``
only validates that the key is present so callers get an early, clear error
instead of an opaque HTTP 401.
"""

def __init__(self) -> None:
self._api_key: str | None = None
self.model_size = "default"

# -- Protocol helpers -----------------------------------------------------

def is_loaded(self) -> bool:
"""Return True once the API key has been located."""
if self._api_key is None:
self._api_key = _load_api_key()
return bool(self._api_key)

def _get_model_path(self, model_size: str = "default") -> str:
"""No local model — report the active API endpoint instead."""
return get_endpoint()

def _is_model_cached(self, model_size: str = "default") -> bool:
"""For a cloud API, "cached" means the API key is configured."""
return self.is_loaded()

def unload_model(self) -> None:
"""No-op: a cloud backend has nothing to unload."""

# -- Model loading --------------------------------------------------------

async def load_model(self, model_size: str = "default") -> None:
"""Validate that the API key is configured; nothing is downloaded."""
if self._api_key is None:
self._api_key = _load_api_key()
if not self._api_key:
raise RuntimeError("MINIMAX_API_KEY is not set. Add it to your environment or to ~/.env.local.")
logger.info("MiniMax TTS backend ready (cloud API, no local model)")

# -- Voice prompt API -----------------------------------------------------

async def create_voice_prompt(
self,
audio_path: str,
reference_text: str,
use_cache: bool = True,
) -> tuple[dict, bool]:
"""Return the default preset voice.

MiniMax uses preset system voices rather than reference audio. Preset
profiles build the voice prompt in the profile service and never reach
this method; it exists only as a safe fallback.
"""
return {
"voice_type": "preset",
"preset_engine": "minimax",
"preset_voice_id": MINIMAX_DEFAULT_VOICE,
}, False

async def combine_voice_prompts(
self,
audio_paths: list[str],
reference_texts: list[str],
) -> tuple[np.ndarray, str]:
"""Combine reference clips — delegates to the shared audio utility."""
return await _combine_voice_prompts(audio_paths, reference_texts, sample_rate=MINIMAX_DEFAULT_SAMPLE_RATE)

# -- Core generation ------------------------------------------------------

async def generate(
self,
text: str,
voice_prompt: dict,
language: str = "en",
seed: int | None = None,
instruct: str | None = None,
) -> tuple[np.ndarray, int]:
"""Synthesize speech through the MiniMax T2A API.

Args:
text: Text to synthesize.
voice_prompt: Dict carrying at least ``preset_voice_id``. Optional
keys ``tts_model``, ``audio_format``, ``sample_rate``, ``speed``,
``vol`` and ``pitch`` override the request controls.
language: ISO language code, mapped to ``language_boost``.
seed: Ignored — the T2A API does not expose a seed.
instruct: Ignored — not supported by the T2A API.

Returns:
Tuple of (audio_array float32, sample_rate).
"""
await self.load_model()

voice_id = voice_prompt.get("preset_voice_id") or MINIMAX_DEFAULT_VOICE

model = voice_prompt.get("tts_model") or MINIMAX_TTS_DEFAULT_MODEL
if model not in MINIMAX_TTS_MODELS:
model = MINIMAX_TTS_DEFAULT_MODEL

audio_format = voice_prompt.get("audio_format") or MINIMAX_DEFAULT_FORMAT
if audio_format not in MINIMAX_AUDIO_FORMATS:
audio_format = MINIMAX_DEFAULT_FORMAT

sample_rate = int(voice_prompt.get("sample_rate") or MINIMAX_DEFAULT_SAMPLE_RATE)
payload = self._build_payload(text, voice_id, model, audio_format, sample_rate, language, voice_prompt)

audio_bytes = await asyncio.to_thread(self._generate_sync, payload)
return await asyncio.to_thread(_decode_audio, audio_bytes, audio_format, sample_rate)

def _build_payload(
self,
text: str,
voice_id: str,
model: str,
audio_format: str,
sample_rate: int,
language: str,
voice_prompt: dict,
) -> dict:
"""Assemble the T2A request body from the supported request controls."""
return {
"model": model,
"text": text,
"stream": False,
"output_format": "hex",
"language_boost": LANGUAGE_BOOST_MAP.get(language, "auto"),
"voice_setting": {
"voice_id": voice_id,
"speed": float(voice_prompt.get("speed", 1.0)),
"vol": float(voice_prompt.get("vol", 1.0)),
"pitch": int(voice_prompt.get("pitch", 0)),
},
"audio_setting": {
"sample_rate": sample_rate,
"format": audio_format,
"channel": 1,
},
}

def _generate_sync(self, payload: dict) -> bytes:
"""Blocking T2A call. Returns the raw (decoded-from-hex) audio bytes."""
import httpx

api_key = self._api_key or _load_api_key()
if not api_key:
raise RuntimeError("MINIMAX_API_KEY is not configured.")

headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
url = get_endpoint()

with httpx.Client(timeout=60.0) as client:
resp = client.post(url, json=payload, headers=headers)
resp.raise_for_status()
body = resp.json()

return self._parse_response(body)

@staticmethod
def _parse_response(body: dict) -> bytes:
"""Validate ``base_resp`` and decode the hex audio from ``data.audio``."""
base_resp = body.get("base_resp") or {}
status_code = base_resp.get("status_code", 0)
if status_code != 0:
msg = base_resp.get("status_msg", "Unknown error")
raise RuntimeError(f"MiniMax TTS API error {status_code}: {msg}")

data = body.get("data") or {}
audio_hex = data.get("audio")
if not audio_hex:
raise RuntimeError(
"MiniMax TTS API returned no audio data. Check your MINIMAX_API_KEY and request parameters."
)

try:
audio_bytes = bytes.fromhex(audio_hex)
except ValueError as exc:
raise RuntimeError("MiniMax TTS API returned malformed audio data.") from exc

logger.debug("MiniMax TTS: decoded %d bytes of audio", len(audio_bytes))
return audio_bytes
2 changes: 1 addition & 1 deletion backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class GenerationRequest(BaseModel):
seed: Optional[int] = Field(None, ge=0)
model_size: Optional[str] = Field(default="1.7B", pattern="^(1\\.7B|0\\.6B|1B|3B)$")
instruct: Optional[str] = Field(None, max_length=500)
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro)$")
engine: Optional[str] = Field(default="qwen", pattern="^(qwen|qwen_custom_voice|luxtts|chatterbox|chatterbox_turbo|tada|kokoro|minimax)$")
personality: bool = Field(
default=False,
description="When true and the profile has a personality prompt, the input text is rewritten in-character before TTS.",
Expand Down
Loading