Files
Owner/src/owner_voice_pet/speech_models.py
T

245 lines
9.4 KiB
Python

from __future__ import annotations
import importlib.util
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .models import ErrorCode, ProviderError
DEFAULT_VAD_URL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"
DEFAULT_STT_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
"sherpa-onnx-streaming-zipformer-ctc-zh-int8-2025-06-30.tar.bz2"
)
DEFAULT_STT_DIR = "sherpa-onnx-streaming-zipformer-ctc-zh-int8-2025-06-30"
DEFAULT_KWS_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/kws-models/"
"sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01-mobile.tar.bz2"
)
DEFAULT_KWS_DIR = "sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01-mobile"
DEFAULT_KWS_KEYWORDS = "x iǎo j ié x iǎo j ié @小杰小杰\n"
DEFAULT_DENOISER_URL = (
"https://github.com/k2-fsa/sherpa-onnx/releases/download/"
"speech-enhancement-models/gtcrn_simple.onnx"
)
DEFAULT_DENOISER_PATH = "denoise/gtcrn_simple.onnx"
REQUIRED_MODEL_FILES = (
f"wake/{DEFAULT_KWS_DIR}/tokens.txt",
f"wake/{DEFAULT_KWS_DIR}/encoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx",
f"wake/{DEFAULT_KWS_DIR}/decoder-epoch-12-avg-2-chunk-16-left-64.onnx",
f"wake/{DEFAULT_KWS_DIR}/joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx",
"wake/keywords.txt",
"vad/silero_vad.onnx",
f"stt/{DEFAULT_STT_DIR}/tokens.txt",
f"stt/{DEFAULT_STT_DIR}/model.int8.onnx",
DEFAULT_DENOISER_PATH,
)
@dataclass(frozen=True, slots=True)
class SpeechModelStatus:
models_dir: Path
manifest_path: Path
manifest_present: bool
required_files: tuple[str, ...]
missing_files: tuple[str, ...]
sherpa_onnx_available: bool
@property
def ok(self) -> bool:
return not self.missing_files and self.sherpa_onnx_available
def to_json(self) -> dict[str, Any]:
return {
"ok": self.ok,
"models_dir": str(self.models_dir),
"manifest_path": str(self.manifest_path),
"manifest_present": self.manifest_present,
"required_files": list(self.required_files),
"missing_files": list(self.missing_files),
"sherpa_onnx_available": self.sherpa_onnx_available,
}
def default_manifest() -> dict[str, Any]:
return {
"schema": "owner_voice_pet.speech_models.v1",
"sample_rate": 16000,
"sources": {
"wake": DEFAULT_KWS_URL,
"vad": DEFAULT_VAD_URL,
"stt": DEFAULT_STT_URL,
"denoiser": DEFAULT_DENOISER_URL,
},
"providers": {
"wake": {
"type": "sherpa-onnx-keyword-spotter",
"model_dir": f"wake/{DEFAULT_KWS_DIR}",
"tokens": f"wake/{DEFAULT_KWS_DIR}/tokens.txt",
"encoder": f"wake/{DEFAULT_KWS_DIR}/encoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx",
"decoder": f"wake/{DEFAULT_KWS_DIR}/decoder-epoch-12-avg-2-chunk-16-left-64.onnx",
"joiner": f"wake/{DEFAULT_KWS_DIR}/joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx",
"keywords": "wake/keywords.txt",
},
"vad": {
"type": "silero-vad",
"path": "vad/silero_vad.onnx",
},
"stt": {
"type": "sherpa-onnx-streaming-zipformer2-ctc",
"model_dir": f"stt/{DEFAULT_STT_DIR}",
"tokens": f"stt/{DEFAULT_STT_DIR}/tokens.txt",
"model": f"stt/{DEFAULT_STT_DIR}/model.int8.onnx",
},
"denoiser": {
"type": "sherpa-onnx-gtcrn",
"path": DEFAULT_DENOISER_PATH,
},
},
"required_files": list(REQUIRED_MODEL_FILES),
}
def write_default_manifest(models_dir: str | Path) -> Path:
root = Path(models_dir)
root.mkdir(parents=True, exist_ok=True)
manifest_path = root / "manifest.json"
manifest_path.write_text(
json.dumps(default_manifest(), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return manifest_path
def load_manifest(models_dir: str | Path) -> dict[str, Any]:
manifest_path = Path(models_dir) / "manifest.json"
if not manifest_path.exists():
return default_manifest()
return json.loads(manifest_path.read_text(encoding="utf-8"))
def required_model_files(models_dir: str | Path) -> tuple[str, ...]:
manifest = load_manifest(models_dir)
files = manifest.get("required_files", list(REQUIRED_MODEL_FILES))
return tuple(str(item) for item in files)
def vad_model_path(models_dir: str | Path) -> Path:
root = Path(models_dir)
manifest = load_manifest(root)
path = manifest.get("providers", {}).get("vad", {}).get("path", "vad/silero_vad.onnx")
return root / str(path)
def denoiser_model_path(models_dir: str | Path) -> Path:
root = Path(models_dir)
manifest = load_manifest(root)
path = manifest.get("providers", {}).get("denoiser", {}).get("path", DEFAULT_DENOISER_PATH)
return root / str(path)
def wake_model_paths(models_dir: str | Path) -> dict[str, Path]:
root = Path(models_dir)
manifest = load_manifest(root)
wake = manifest.get("providers", {}).get("wake", {})
return {
"model_dir": root / str(wake.get("model_dir", f"wake/{DEFAULT_KWS_DIR}")),
"tokens": root / str(wake.get("tokens", f"wake/{DEFAULT_KWS_DIR}/tokens.txt")),
"encoder": root
/ str(wake.get("encoder", f"wake/{DEFAULT_KWS_DIR}/encoder-epoch-12-avg-2-chunk-16-left-64.int8.onnx")),
"decoder": root
/ str(wake.get("decoder", f"wake/{DEFAULT_KWS_DIR}/decoder-epoch-12-avg-2-chunk-16-left-64.onnx")),
"joiner": root
/ str(wake.get("joiner", f"wake/{DEFAULT_KWS_DIR}/joiner-epoch-12-avg-2-chunk-16-left-64.int8.onnx")),
"keywords": root / str(wake.get("keywords", "wake/keywords.txt")),
}
def stt_model_paths(model_path: str | Path) -> dict[str, Any]:
root = Path(model_path)
if (root / "manifest.json").exists() or (root / "stt").exists():
manifest = load_manifest(root)
stt = manifest.get("providers", {}).get("stt", {})
stt_type = str(stt.get("type", "sherpa-onnx-streaming-transducer"))
if stt_type == "sherpa-onnx-streaming-zipformer2-ctc":
return {
"type": stt_type,
"model_dir": root / str(stt.get("model_dir", f"stt/{DEFAULT_STT_DIR}")),
"tokens": root / str(stt.get("tokens", f"stt/{DEFAULT_STT_DIR}/tokens.txt")),
"model": root / str(stt.get("model", f"stt/{DEFAULT_STT_DIR}/model.int8.onnx")),
}
return {
"type": stt_type,
"model_dir": root / str(stt.get("model_dir", f"stt/{DEFAULT_STT_DIR}")),
"tokens": root / str(stt.get("tokens", f"stt/{DEFAULT_STT_DIR}/tokens.txt")),
"encoder": root / str(stt.get("encoder", f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx")),
"decoder": root / str(stt.get("decoder", f"stt/{DEFAULT_STT_DIR}/decoder-epoch-99-avg-1.onnx")),
"joiner": root / str(stt.get("joiner", f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx")),
}
return {
"type": "sherpa-onnx-streaming-transducer",
"model_dir": root,
"tokens": root / "tokens.txt",
"encoder": root / "encoder-epoch-99-avg-1.int8.onnx",
"decoder": root / "decoder-epoch-99-avg-1.onnx",
"joiner": root / "joiner-epoch-99-avg-1.int8.onnx",
}
def check_speech_models(models_dir: str | Path, require_sherpa: bool = True) -> SpeechModelStatus:
root = Path(models_dir)
manifest_path = root / "manifest.json"
required = required_model_files(root)
missing = tuple(relative for relative in required if not (root / relative).exists())
sherpa_available = True
if require_sherpa:
sherpa_available = importlib.util.find_spec("sherpa_onnx") is not None
return SpeechModelStatus(
models_dir=root,
manifest_path=manifest_path,
manifest_present=manifest_path.exists(),
required_files=required,
missing_files=missing,
sherpa_onnx_available=sherpa_available,
)
def model_status_errors(status: SpeechModelStatus) -> list[ProviderError]:
errors: list[ProviderError] = []
if not status.sherpa_onnx_available:
errors.append(
ProviderError(
ErrorCode.STT_TRANSCRIBE_FAILED,
"sherpa_onnx is not installed in the active Python environment",
False,
"sherpa-onnx",
"model-check",
)
)
denoise_missing = tuple(item for item in status.missing_files if item.startswith("denoise/"))
speech_missing = tuple(item for item in status.missing_files if not item.startswith("denoise/"))
if speech_missing:
errors.append(
ProviderError(
ErrorCode.STT_MODEL_MISSING,
"missing speech model files: " + ", ".join(speech_missing),
False,
"speech-models",
"model-check",
)
)
if denoise_missing:
errors.append(
ProviderError(
ErrorCode.NOISE_FILTER_MODEL_MISSING,
"missing denoiser model files: " + ", ".join(denoise_missing),
False,
"speech-models",
"model-check",
)
)
return errors