summaryrefslogtreecommitdiff
path: root/__init__.py
blob: a49839c8b9f3553114f2a825a50df676cb3024c8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""MiMo V2.5 TTS Provider Plugin for Hermes Agent.

Integrates Xiaomi's MiMo V2.5 Text-to-Speech API as a Hermes TTS provider.
Supports three models:
  - mimo-v2.5-tts          — built-in high-quality voices
  - mimo-v2.5-tts-voicedesign — custom voice from text description
  - mimo-v2.5-tts-voiceclone  — replicate voice from audio samples

Requires: XIAOMI_API_KEY environment variable.
"""

from __future__ import annotations

import base64
import json
import logging
import os
from typing import Any, Dict, List, Optional

from agent.tts_provider import TTSProvider, DEFAULT_OUTPUT_FORMAT, VALID_OUTPUT_FORMATS

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_API_BASE_DEFAULT = "https://api.xiaomimimo.com/v1"


def _get_api_base() -> str:
    """Return the MiMo API base URL.

    Checks XIAOMI_BASE_URL env var first (matches the LLM provider config),
    then falls back to the public endpoint.
    """
    return os.environ.get("XIAOMI_BASE_URL", _API_BASE_DEFAULT).rstrip("/")


_MODEL_DEFAULT = "mimo-v2.5-tts"
_VOICE_DEFAULT = "mimo_default"

# Format mapping: Hermes format → MiMo API format
_FORMAT_MAP = {
    "mp3": "wav",   # MiMo doesn't output mp3; fall back to wav
    "wav": "wav",
    "ogg": "wav",
    "opus": "wav",
    "flac": "wav",
}

_BUILTIN_VOICES: List[Dict[str, Any]] = [
    {"id": "mimo_default", "display": "MiMo Default (auto-select cluster default)", "language": "auto", "gender": ""},
    {"id": "冰糖", "display": "Bingtang (冰糖)", "language": "zh", "gender": "female"},
    {"id": "茉莉", "display": "Moli (茉莉)", "language": "zh", "gender": "female"},
    {"id": "苏打", "display": "Suda (苏打)", "language": "zh", "gender": "male"},
    {"id": "白桦", "display": "Baihua (白桦)", "language": "zh", "gender": "male"},
    {"id": "Mia", "display": "Mia", "language": "en", "gender": "female"},
    {"id": "Chloe", "display": "Chloe", "language": "en", "gender": "female"},
    {"id": "Milo", "display": "Milo", "language": "en", "gender": "male"},
    {"id": "Dean", "display": "Dean", "language": "en", "gender": "male"},
]

_MODELS: List[Dict[str, Any]] = [
    {"id": "mimo-v2.5-tts", "display": "MiMo V2.5 TTS (built-in voices)", "max_text_length": 10000},
    {"id": "mimo-v2.5-tts-voicedesign", "display": "MiMo V2.5 TTS Voice Design", "max_text_length": 10000},
    {"id": "mimo-v2.5-tts-voiceclone", "display": "MiMo V2.5 TTS Voice Clone", "max_text_length": 10000},
]


# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------

class MiMoTTSProvider(TTSProvider):
    """Xiaomi MiMo V2.5 Text-to-Speech provider."""

    @property
    def name(self) -> str:
        return "mimo"

    @property
    def display_name(self) -> str:
        return "MiMo TTS (Xiaomi)"

    def is_available(self) -> bool:
        return bool(os.environ.get("XIAOMI_API_KEY"))

    def list_voices(self) -> List[Dict[str, Any]]:
        return _BUILTIN_VOICES

    def list_models(self) -> List[Dict[str, Any]]:
        return _MODELS

    def default_model(self) -> Optional[str]:
        return _MODEL_DEFAULT

    def default_voice(self) -> Optional[str]:
        return _VOICE_DEFAULT

    def get_setup_schema(self) -> Dict[str, Any]:
        return {
            "name": "MiMo TTS (Xiaomi)",
            "badge": "paid",
            "tag": "V2.5 — built-in voices, voice design, voice cloning",
            "env_vars": [
                {
                    "key": "XIAOMI_API_KEY",
                    "prompt": "Xiaomi MiMo API key",
                    "url": "https://mimo.mi.com",
                },
            ],
        }

    @property
    def voice_compatible(self) -> bool:
        return True

    def synthesize(
        self,
        text: str,
        output_path: str,
        *,
        voice: Optional[str] = None,
        model: Optional[str] = None,
        speed: Optional[float] = None,
        format: str = DEFAULT_OUTPUT_FORMAT,
        **extra: Any,
    ) -> str:
        """Synthesize text via MiMo V2.5 TTS API.

        The text is placed in the ``assistant`` message (per MiMo API spec).
        Style instructions (from ``extra["style"]``) go in the ``user`` message.
        """
        import urllib.request

        api_key = os.environ.get("XIAOMI_API_KEY")
        if not api_key:
            raise RuntimeError(
                "XIAOMI_API_KEY environment variable is not set. "
                "Get your key at https://mimo.mi.com"
            )

        model_id = model or _MODEL_DEFAULT
        voice_id = voice or _VOICE_DEFAULT
        mimo_format = _FORMAT_MAP.get(format, "wav")

        # Build messages
        messages: list = []

        # Style instruction in user message (optional)
        style = extra.get("style")
        if style:
            messages.append({"role": "user", "content": str(style)})

        # Text to synthesize in assistant message
        messages.append({"role": "assistant", "content": text})

        # Build request body
        # voice-design and voice-clone models don't accept the voice field
        audio_config: Dict[str, Any] = {"format": mimo_format}
        if "voicedesign" not in model_id and "voiceclone" not in model_id:
            audio_config["voice"] = voice_id
        body: Dict[str, Any] = {
            "model": model_id,
            "messages": messages,
            "audio": audio_config,
        }

        # Speed control via style instructions if speed is set
        # (MiMo doesn't have a direct speed param; we prepend to style)
        if speed is not None and speed != 1.0 and not style:
            speed_desc = "faster" if speed > 1.0 else "slower"
            body["messages"].insert(0, {
                "role": "user",
                "content": f"Speak at a slightly {speed_desc} pace than normal.",
            })

        data = json.dumps(body).encode("utf-8")

        req = urllib.request.Request(
            f"{_get_api_base()}/chat/completions",
            data=data,
            headers={
                "Content-Type": "application/json",
                "api-key": api_key,
            },
            method="POST",
        )

        logger.info("MiMo TTS: synthesizing %d chars with model=%s voice=%s", len(text), model_id, voice_id)

        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                resp_data = json.loads(resp.read().decode("utf-8"))
        except Exception as exc:
            raise RuntimeError(f"MiMo TTS API request failed: {exc}") from exc

        # Extract audio from response
        try:
            audio_b64 = resp_data["choices"][0]["message"]["audio"]["data"]
        except (KeyError, IndexError, TypeError) as exc:
            raise RuntimeError(
                f"MiMo TTS API returned unexpected response structure: {exc}\n"
                f"Response: {json.dumps(resp_data, ensure_ascii=False)[:500]}"
            ) from exc

        audio_bytes = base64.b64decode(audio_b64)

        # Ensure output has correct extension for the actual format
        # MiMo always returns wav regardless of what we asked
        if not output_path.endswith(".wav"):
            output_path = os.path.splitext(output_path)[0] + ".wav"

        os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
        with open(output_path, "wb") as f:
            f.write(audio_bytes)

        logger.info("MiMo TTS: wrote %d bytes to %s", len(audio_bytes), output_path)
        return output_path

    def stream(
        self,
        text: str,
        *,
        voice: Optional[str] = None,
        model: Optional[str] = None,
        format: str = "opus",
        **extra: Any,
    ):
        """Stream synthesized audio chunks via MiMo V2.5 TTS streaming API."""
        import urllib.request

        api_key = os.environ.get("XIAOMI_API_KEY")
        if not api_key:
            raise RuntimeError("XIAOMI_API_KEY environment variable is not set.")

        model_id = model or _MODEL_DEFAULT
        voice_id = voice or _VOICE_DEFAULT

        messages: list = []
        style = extra.get("style")
        if style:
            messages.append({"role": "user", "content": str(style)})
        messages.append({"role": "assistant", "content": text})

        audio_config: Dict[str, Any] = {"format": "pcm16"}
        if "voicedesign" not in model_id and "voiceclone" not in model_id:
            audio_config["voice"] = voice_id
        body = {
            "model": model_id,
            "messages": messages,
            "audio": audio_config,
            "stream": True,
        }

        data = json.dumps(body).encode("utf-8")
        req = urllib.request.Request(
            f"{_get_api_base()}/chat/completions",
            data=data,
            headers={
                "Content-Type": "application/json",
                "api-key": api_key,
            },
            method="POST",
        )

        logger.info("MiMo TTS streaming: synthesizing %d chars", len(text))

        with urllib.request.urlopen(req, timeout=120) as resp:
            buffer = b""
            for line in resp:
                line = line.strip()
                if not line:
                    continue
                # SSE format: "data: {...}"
                if line.startswith(b"data: "):
                    payload = line[6:]
                    if payload == b"[DONE]":
                        break
                    try:
                        chunk = json.loads(payload)
                        delta = chunk.get("choices", [{}])[0].get("delta", {})
                        audio_data = delta.get("audio", {})
                        if isinstance(audio_data, dict) and "data" in audio_data:
                            pcm_bytes = base64.b64decode(audio_data["data"])
                            yield pcm_bytes
                    except (json.JSONDecodeError, KeyError, IndexError):
                        continue


# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------

def register(ctx):
    """Hermes plugin entry point — register the MiMo TTS provider."""
    ctx.register_tts_provider(MiMoTTSProvider())