Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

514 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
STT (Speech-to-Text) engine for oMLX.
This module provides an engine for audio transcription using mlx-audio.
Unlike LLM engines, STT engines don't support chat completion. Transcription
results can be streamed incrementally via transcribe_stream() for models
whose mlx-audio backend supports it.
mlx-audio is imported lazily inside start() to avoid module-level import errors
when mlx-audio is not installed.
"""
import asyncio
import gc
import logging
from collections.abc import AsyncIterator
from typing import Any
import mlx.core as mx
from ..engine_core import get_mlx_executor
from .base import BaseNonStreamingEngine
logger = logging.getLogger(__name__)
# Lowercase full-names are needed for Qwen3-ASR-style prompt builders whose
# support_languages list contains names such as "Chinese" and "English".
_ISO_TO_STT_LANG: dict[str, str] = {
"zh": "chinese",
"yue": "cantonese",
"en": "english",
"de": "german",
"es": "spanish",
"fr": "french",
"it": "italian",
"pt": "portuguese",
"ru": "russian",
"ko": "korean",
"ja": "japanese",
}
def _stt_model_expects_language_names(model: Any) -> bool:
"""Return True for STT backends whose language hints are full names."""
config = getattr(model, "config", None)
support_languages = getattr(config, "support_languages", None)
if not support_languages:
return False
if isinstance(support_languages, str):
support_languages = [support_languages]
supported = {
str(lang).strip().lower() for lang in support_languages if str(lang).strip()
}
return bool(supported & set(_ISO_TO_STT_LANG.values()))
def _normalize_stt_generate_language(
model: Any,
language: str | None,
) -> str | None:
"""Normalize API language hints for the specific mlx-audio STT backend."""
if language is None:
return None
normalized = language.strip()
if not normalized:
return None
if _stt_model_expects_language_names(model):
return _ISO_TO_STT_LANG.get(normalized.lower(), normalized)
return normalized
# ---------------------------------------------------------------------------
# Error helpers (#800): turn opaque mlx-audio/HF processor failures into
# actionable RuntimeErrors that tell users which file is missing and where
# to find a compatible variant.
# ---------------------------------------------------------------------------
_MISSING_PROCESSOR_HINTS = (
"preprocessor_config.json",
"feature extractor",
"featureextractor",
)
def _looks_like_missing_processor(message: str) -> bool:
"""True if the error text from mlx-audio / HF points at a missing processor."""
lowered = message.lower()
return any(h in lowered for h in _MISSING_PROCESSOR_HINTS)
def _missing_processor_hint(model_name: str) -> str:
return (
f"STT model '{model_name}' is missing the HuggingFace processor / "
"feature-extractor configuration (preprocessor_config.json and/or "
"tokenizer files). MLX-converted repositories sometimes omit these. "
"Fix: either use an HF-compatible variant of the model or copy "
"preprocessor_config.json, tokenizer.json and special_tokens_map.json "
"from the upstream HuggingFace repo into the local model directory."
)
def _normalize_result_language(raw_lang: Any) -> Any:
"""Normalize the language field returned by mlx-audio backends."""
if isinstance(raw_lang, list):
raw_lang = raw_lang[0] if raw_lang else None
if isinstance(raw_lang, str) and raw_lang.lower() == "none":
return None
return raw_lang
def _map_stt_prompt_kwargs(model: Any, prompt: str | None) -> dict[str, str]:
"""Map the OpenAI ``prompt`` field onto the backend's biasing hook.
Qwen3-ASR-style backends expose ``generate(..., system_prompt=...)`` —
a trained context-injection mechanism with strong biasing. Whisper-family
backends expose ``generate(..., initial_prompt=...)`` — a decoder-prefix
soft prior (~224-token window). Backends with neither hook ignore the
field; a request must never fail because of ``prompt``.
"""
if prompt is None or not prompt.strip():
return {}
import inspect
try:
params = inspect.signature(model.generate).parameters
except (TypeError, ValueError):
return {}
if "system_prompt" in params:
return {"system_prompt": prompt}
if "initial_prompt" in params:
return {"initial_prompt": prompt}
logger.debug(
"STT backend %s has no prompt-biasing hook; ignoring 'prompt'",
type(model).__name__,
)
return {}
def _wrap_stt_load_error(model_name: str, exc: Exception) -> Exception:
"""Return a clearer exception for known mlx-audio STT load failures."""
message = str(exc)
if _looks_like_missing_processor(message):
return RuntimeError(
f"{_missing_processor_hint(model_name)} Original error: {message}"
)
return exc
def _validate_stt_processor(model_name: str, model: Any) -> None:
"""Fail fast if a Whisper-family mlx-audio model loaded without a processor."""
module_name = type(model).__module__ or ""
is_whisper_like = "whisper" in module_name.lower()
if not is_whisper_like:
return
# mlx-audio Whisper attaches a HF processor to ``_processor``; it's set
# to None when WhisperProcessor.from_pretrained() failed on load.
if not hasattr(model, "_processor"):
return
if model._processor is not None:
return
raise RuntimeError(_missing_processor_hint(model_name))
class STTEngine(BaseNonStreamingEngine):
"""
Engine for audio transcription (Speech-to-Text).
This engine wraps mlx-audio STT models and provides async methods
for integration with the oMLX server.
Unlike BaseEngine, this doesn't support chat. transcribe() computes the
full result in one pass; transcribe_stream() yields incremental chunks
for mlx-audio backends that expose ``generate(..., stream=True)``.
"""
def __init__(self, model_name: str, **kwargs):
"""
Initialize the STT engine.
Args:
model_name: HuggingFace model name or local path
**kwargs: Additional model-specific parameters
"""
super().__init__()
self._model_name = model_name
self._model = None
self._kwargs = kwargs
@property
def model_name(self) -> str:
"""Get the model name."""
return self._model_name
async def start(self) -> None:
"""Start the engine (load model if not loaded).
Model loading runs on the global MLX executor to avoid Metal
command buffer races with concurrent BatchGenerator steps.
mlx-audio is imported here (lazily) to avoid module-level errors
when the package is not installed.
"""
if self._model is not None:
return
logger.info(f"Starting STT engine: {self._model_name}")
try:
from mlx_audio.stt.utils import load_model as _load_model
except ImportError as exc:
raise ImportError(
"mlx-audio is required for STT inference. "
'Install it with: pip install "omlx[audio]"'
) from exc
model_name = self._model_name
def _load_sync():
# load_model returns a single nn.Module, not a tuple
return _load_model(model_name)
loop = asyncio.get_running_loop()
try:
model = await loop.run_in_executor(get_mlx_executor(), _load_sync)
except Exception as exc:
# #800: MLX-packaged repos (Qwen3-ASR-*-MLX-*, some mlx-community
# whisper variants) often omit preprocessor_config.json, which
# mlx-audio / HuggingFace AutoFeatureExtractor reports with an
# opaque OSError. Re-raise with an actionable message instead.
raise _wrap_stt_load_error(model_name, exc) from exc
# #800: Whisper models in mlx-audio load silently without a
# HuggingFace processor when preprocessor_config.json is missing
# (mlx-audio only emits a warning). Fail fast at start so callers
# see the real problem instead of a downstream "Processor not found"
# 500 during transcribe.
_validate_stt_processor(model_name, model)
self._model = model
logger.info(f"STT engine started: {self._model_name}")
async def stop(self) -> None:
"""Stop the engine and cleanup resources."""
if self._model is None:
return
logger.info(f"Stopping STT engine: {self._model_name}")
self._model = None
gc.collect()
loop = asyncio.get_running_loop()
await loop.run_in_executor(
get_mlx_executor(), lambda: (mx.synchronize(), mx.clear_cache())
)
logger.info(f"STT engine stopped: {self._model_name}")
async def transcribe(
self,
audio_path: str,
language: str | None = None,
prompt: str | None = None,
**kwargs,
) -> dict[str, Any]:
"""
Transcribe an audio file.
Args:
audio_path: Path to the audio file to transcribe
language: Optional language code (e.g. 'en', 'fr')
prompt: Optional vocabulary / context biasing text (OpenAI
``prompt`` field), mapped onto the backend's biasing hook
(Qwen3-ASR ``system_prompt``, Whisper ``initial_prompt``);
ignored by backends without one
**kwargs: Additional model-specific parameters
Returns:
Dictionary with keys:
text: Transcribed text
language: Detected or specified language
segments: List of timed segments (may be empty)
duration: Audio duration in seconds
"""
if self._model is None:
raise RuntimeError("Engine not started. Call start() first.")
import os
import time
file_size = os.path.getsize(audio_path) if os.path.exists(audio_path) else 0
logger.info(
"STT transcribe: model=%s, file=%s (%d bytes), language=%s",
self._model_name, os.path.basename(audio_path), file_size, language,
)
model = self._model
t0 = time.monotonic()
def _normalize_segment(s) -> dict:
"""Convert any segment type to a plain dict."""
if isinstance(s, dict):
return s
# dataclass → asdict
import dataclasses
if dataclasses.is_dataclass(s) and not isinstance(s, type):
return dataclasses.asdict(s)
# object with __dict__
if hasattr(s, "__dict__"):
return vars(s)
return {"text": str(s)}
def _transcribe_sync():
# Call model.generate() directly instead of
# generate_transcription() which writes files to disk.
gen_kwargs = dict(kwargs)
generate_language = _normalize_stt_generate_language(model, language)
if generate_language is not None:
gen_kwargs["language"] = generate_language
gen_kwargs.update(_map_stt_prompt_kwargs(model, prompt))
result = model.generate(audio_path, **gen_kwargs)
# result is typically an STTOutput dataclass with:
# text, segments, language, total_time, etc.
if hasattr(result, "text"):
raw_lang = _normalize_result_language(
getattr(result, "language", None)
)
if raw_lang is None:
raw_lang = language
raw_segs = getattr(result, "segments", None)
segments = [
_normalize_segment(s) for s in raw_segs
] if raw_segs else []
return {
"text": result.text or "",
"language": raw_lang,
"segments": segments,
"duration": getattr(
result, "total_time", 0.0
),
}
# Fallback for unexpected return types
return {
"text": str(result),
"language": language,
"segments": [],
"duration": 0.0,
}
activity_id = self._begin_activity(
"transcribing",
detail="Transcribing",
metadata={"file_size_bytes": file_size},
)
try:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
get_mlx_executor(), _transcribe_sync
)
elapsed = time.monotonic() - t0
text_len = len(result.get("text", ""))
logger.info(
"STT transcribe done: model=%s, %.2fs, %d chars output",
self._model_name, elapsed, text_len,
)
return result
finally:
await self._finish_activity(activity_id)
def supports_native_stt_streaming(self) -> bool:
"""True when the loaded model's generate() accepts a ``stream`` flag.
mlx-audio streaming-capable backends (whisper, parakeet, canary,
qwen3-asr, ...) all expose ``generate(..., stream: bool)`` that
returns a generator of incremental results.
"""
if self._model is None:
return False
import inspect
try:
params = inspect.signature(self._model.generate).parameters
except (TypeError, ValueError):
return False
return "stream" in params
async def transcribe_stream(
self,
audio_path: str,
language: str | None = None,
prompt: str | None = None,
**kwargs,
) -> AsyncIterator[dict[str, Any]]:
"""Stream transcription chunks as the model decodes them.
``prompt`` is the OpenAI vocabulary / context biasing field, mapped
onto the backend's biasing hook exactly as in transcribe().
Yields dicts with keys:
text: Incremental text delta for this chunk
language: Detected or specified language (may be None)
prompt_tokens: Cumulative prompt token count (0 if unknown)
generation_tokens: Cumulative generated token count (0 if unknown)
Models whose generate() lacks native streaming support fall back to
one-shot transcribe() and yield a single chunk with the full text.
"""
if self._model is None:
raise RuntimeError("Engine not started. Call start() first.")
if not self.supports_native_stt_streaming():
result = await self.transcribe(
audio_path, language=language, prompt=prompt, **kwargs
)
yield {
"text": result.get("text", ""),
"language": result.get("language"),
"prompt_tokens": 0,
"generation_tokens": 0,
}
return
import os
import time
file_size = os.path.getsize(audio_path) if os.path.exists(audio_path) else 0
logger.info(
"STT stream transcribe: model=%s, file=%s (%d bytes), language=%s",
self._model_name, os.path.basename(audio_path), file_size, language,
)
model = self._model
t0 = time.monotonic()
gen_kwargs = dict(kwargs)
generate_language = _normalize_stt_generate_language(model, language)
if generate_language is not None:
gen_kwargs["language"] = generate_language
gen_kwargs.update(_map_stt_prompt_kwargs(model, prompt))
gen_kwargs["stream"] = True
iterator: Any = None
sentinel = object()
def _next_chunk():
nonlocal iterator
if iterator is None:
iterator = iter(model.generate(audio_path, **gen_kwargs))
try:
result = next(iterator)
except StopIteration:
return sentinel
if isinstance(result, str):
return {
"text": result,
"language": None,
"prompt_tokens": 0,
"generation_tokens": 0,
}
return {
"text": getattr(result, "text", "") or "",
"language": _normalize_result_language(
getattr(result, "language", None)
),
"prompt_tokens": int(getattr(result, "prompt_tokens", 0) or 0),
"generation_tokens": int(
getattr(result, "generation_tokens", 0) or 0
),
}
activity_id = self._begin_activity(
"transcribing",
detail="Streaming transcription",
metadata={"file_size_bytes": file_size},
)
chunk_count = 0
text_len = 0
try:
loop = asyncio.get_running_loop()
while True:
chunk = await loop.run_in_executor(get_mlx_executor(), _next_chunk)
if chunk is sentinel:
break
chunk_count += 1
text_len += len(chunk["text"])
yield chunk
finally:
await self._finish_activity(activity_id)
logger.info(
"STT stream transcribe done: model=%s, %.2fs, chunks=%d, %d chars",
self._model_name, time.monotonic() - t0, chunk_count, text_len,
)
def get_stats(self) -> dict[str, Any]:
"""Get engine statistics."""
return {
"model_name": self._model_name,
"loaded": self._model is not None,
}
def __repr__(self) -> str:
status = "running" if self._model is not None else "stopped"
return f"<STTEngine model={self._model_name} status={status}>"