diff options
Diffstat (limited to 'provider.py')
| -rw-r--r-- | provider.py | 184 |
1 files changed, 184 insertions, 0 deletions
diff --git a/provider.py b/provider.py new file mode 100644 index 0000000..cfd69dd --- /dev/null +++ b/provider.py @@ -0,0 +1,184 @@ +"""Xiaomi MiMo ASR — plugin form. + +Subclasses :class:`agent.stt_provider.STTProvider`. Sends audio to MiMo's +chat/completions API with ``input_audio`` format and returns transcribed text. + +Xiaomi ASR does **not** use the standard ``/v1/audio/transcriptions`` endpoint. +Audio must be wav or mp3; other formats are auto-converted via ffmpeg. + +Auth env var:: + + XIAOMI_API_KEY=... # https://platform.xiaomimimo.com + XIAOMI_BASE_URL=... # optional, defaults to https://api.xiaomimimo.com/v1 +""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import ssl +import subprocess +import tempfile +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional + +from agent.stt_provider import STTProvider + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "mimo-v2.5-asr" +DEFAULT_LANGUAGE = "zh" +_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1" + +_MODELS: List[Dict[str, Any]] = [ + {"id": "mimo-v2.5-asr", "display": "MiMo V2.5 ASR", "languages": ["zh", "en"]}, +] + + +def _get_api_base() -> str: + return os.environ.get("XIAOMI_BASE_URL", _DEFAULT_BASE_URL).rstrip("/") + + +class MiMoSTTProvider(STTProvider): + """Xiaomi MiMo ASR (Speech-to-Text) provider.""" + + @property + def name(self) -> str: + return "mimo-asr" + + @property + def display_name(self) -> str: + return "MiMo ASR (Xiaomi)" + + def is_available(self) -> bool: + return bool(os.environ.get("XIAOMI_API_KEY")) + + def list_models(self) -> List[Dict[str, Any]]: + return _MODELS + + def default_model(self) -> Optional[str]: + return DEFAULT_MODEL + + def default_language(self) -> Optional[str]: + return DEFAULT_LANGUAGE + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "MiMo ASR (Xiaomi)", + "badge": "paid", + "tag": "V2.5 — speech-to-text, optimized for Chinese.", + "env_vars": [ + { + "key": "XIAOMI_API_KEY", + "prompt": "Xiaomi MiMo API key", + "url": "https://platform.xiaomimimo.com", + }, + ], + } + + def transcribe( + self, + audio_path: str, + *, + model: Optional[str] = None, + language: Optional[str] = None, + **extra: Any, + ) -> str: + 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://platform.xiaomimimo.com" + ) + + model_id = model or DEFAULT_MODEL + model_id = model_id.strip() + language = language or DEFAULT_LANGUAGE + + audio_path = audio_path.replace("\\", "/") # normalize Windows paths + + ext = os.path.splitext(audio_path)[1].lower() + if ext in (".wav",): + fmt = "wav" + audio_file = audio_path + elif ext in (".mp3",): + fmt = "mp3" + audio_file = audio_path + else: + fmt = "wav" + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True) + except (FileNotFoundError, subprocess.CalledProcessError): + raise RuntimeError( + f"ffmpeg is required to convert {ext} to wav/mp3 for Xiaomi ASR" + ) + + fd, audio_file = tempfile.mkstemp(suffix=".wav", prefix="hermes-asr-") + os.close(fd) + try: + subprocess.run( + ["ffmpeg", "-y", "-i", audio_path, "-ar", "16000", "-ac", "1", + "-f", "wav", audio_file], + capture_output=True, check=True, + ) + except subprocess.CalledProcessError as e: + os.unlink(audio_file) + raise RuntimeError( + f"ffmpeg conversion failed: {e.stderr.decode(errors='replace')}" + ) from e + + try: + with open(audio_file, "rb") as f: + audio_b64 = base64.b64encode(f.read()).decode() + + payload = json.dumps({ + "model": model_id, + "messages": [{ + "role": "user", + "content": [ + {"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}}, + ] + }] + }).encode() + + req = urllib.request.Request( + f"{_get_api_base()}/chat/completions", + data=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + ctx = ssl.create_default_context() + + logger.info("MiMo ASR: transcribing %s with model=%s language=%s", audio_path, model_id, language) + + resp = urllib.request.urlopen(req, context=ctx, timeout=300) + response_data = json.loads(resp.read().decode()) + + choices = response_data.get("choices", []) + if not choices: + error_info = response_data.get("error", {}) + raise RuntimeError( + error_info.get("message", "No transcription result") + ) + + msg = choices[0].get("message", {}) + transcript = msg.get("content", "").strip() + + if not transcript: + raise RuntimeError("Xiaomi ASR returned empty transcript") + + return transcript + + except urllib.error.HTTPError as e: + body = e.read().decode(errors="replace") + raise RuntimeError(f"HTTP {e.code}: {body}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"URL error: {e.reason}") from e + finally: + if 'audio_file' in locals() and audio_file != audio_path and os.path.exists(audio_file): + os.unlink(audio_file) |
