[依赖、模型和配置]:完成本地语音模型准备,包含.env配置、模型下载脚本和model-check
This commit is contained in:
@@ -6,3 +6,9 @@ OWNER_AUDIO_INPUT_DEVICE=
|
|||||||
OWNER_AUDIO_OUTPUT_DEVICE=
|
OWNER_AUDIO_OUTPUT_DEVICE=
|
||||||
OWNER_ASSET_DIR=assets/pet
|
OWNER_ASSET_DIR=assets/pet
|
||||||
OWNER_LOG_DIR=logs
|
OWNER_LOG_DIR=logs
|
||||||
|
OWNER_SPEECH_MODELS_DIR=models
|
||||||
|
OWNER_CONTEXT_MAX_MESSAGES=12
|
||||||
|
OWNER_CONTEXT_MAX_CHARS=12000
|
||||||
|
OWNER_WAKE_WORD=小杰小杰
|
||||||
|
OWNER_SAMPLE_RATE=16000
|
||||||
|
OWNER_CHANNELS=1
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.venv/
|
.venv/
|
||||||
|
models/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.aiff
|
||||||
|
*.wav
|
||||||
|
*.flac
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
|
|
||||||
## 2. 依赖、模型和配置规划落地
|
## 2. 依赖、模型和配置规划落地
|
||||||
|
|
||||||
- [ ] 2.1 更新 `.gitignore` 忽略 `models/`、`.venv/`、TTS 临时音频和 Python 缓存;前置条件:OpenSpec 提交完成;验收标准:模型和本地密钥不会出现在 git status;测试要点:`git status --short` 不列出模型大文件;优先级:P0;预计:20 分钟。
|
- [x] 2.1 更新 `.gitignore` 忽略 `models/`、`.venv/`、TTS 临时音频和 Python 缓存;前置条件:OpenSpec 提交完成;验收标准:模型和本地密钥不会出现在 git status;测试要点:`git status --short` 不列出模型大文件;优先级:P0;预计:20 分钟。
|
||||||
- [ ] 2.2 更新 `.env.example`,加入模型目录、上下文预算和 live runtime 相关配置;前置条件:配置字段确认;验收标准:用户复制后可填写 key 并运行;测试要点:配置加载测试覆盖默认值;优先级:P0;预计:30 分钟。
|
- [x] 2.2 更新 `.env.example`,加入模型目录、上下文预算和 live runtime 相关配置;前置条件:配置字段确认;验收标准:用户复制后可填写 key 并运行;测试要点:配置加载测试覆盖默认值;优先级:P0;预计:30 分钟。
|
||||||
- [ ] 2.3 新增 `scripts/download_speech_models.py --dir models`;前置条件:确认模型来源;验收标准:脚本能创建模型目录、下载/解压模型、写入 manifest;测试要点:脚本参数解析和 manifest 测试通过;优先级:P0;预计:60 分钟。
|
- [x] 2.3 新增 `scripts/download_speech_models.py --dir models`;前置条件:确认模型来源;验收标准:脚本能创建模型目录、下载/解压模型、写入 manifest;测试要点:脚本参数解析和 manifest 测试通过;优先级:P0;预计:60 分钟。
|
||||||
- [ ] 2.4 在项目本地 `.venv` 安装 `sounddevice`、`numpy`、`sherpa-onnx`;前置条件:`python3.11` 可用;验收标准:`.venv/bin/python -c` 可导入三个依赖;测试要点:不污染全局环境;优先级:P0;预计:30 分钟。
|
- [x] 2.4 在项目本地 `.venv` 安装 `sounddevice`、`numpy`、`sherpa-onnx`;前置条件:`python3.11` 可用;验收标准:`.venv/bin/python -c` 可导入三个依赖;测试要点:不污染全局环境;优先级:P0;预计:30 分钟。
|
||||||
- [ ] 2.5 下载模型到 `models/` 并运行 `model-check` 预备验证;前置条件:下载脚本完成;验收标准:模型文件存在且未进入 Git;测试要点:`git status --short` 不列出模型文件;优先级:P0;预计:60 分钟。
|
- [x] 2.5 下载模型到 `models/` 并运行 `model-check` 预备验证;前置条件:下载脚本完成;验收标准:模型文件存在且未进入 Git;测试要点:`git status --short` 不列出模型文件;优先级:P0;预计:60 分钟。
|
||||||
- [ ] 2.6 验证并提交“依赖、模型和配置”模块;前置条件:2.1 至 2.5 完成;验收标准:compileall、相关测试、security-check、OpenSpec strict 通过后 commit;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。
|
- [x] 2.6 验证并提交“依赖、模型和配置”模块;前置条件:2.1 至 2.5 完成;验收标准:compileall、相关测试、security-check、OpenSpec strict 通过后 commit;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。
|
||||||
|
|
||||||
## 3. 本机音频 Transport 与设备检查
|
## 3. 本机音频 Transport 与设备检查
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ authors = [{ name = "mkbk" }]
|
|||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
audio = ["sounddevice>=0.4.7"]
|
audio = ["numpy>=1.26", "sounddevice>=0.4.7"]
|
||||||
|
speech = ["numpy>=1.26", "sounddevice>=0.4.7", "sherpa-onnx>=1.13.3"]
|
||||||
ui = ["PySide6>=6.7"]
|
ui = ["PySide6>=6.7"]
|
||||||
test = []
|
test = []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC_DIR = PROJECT_ROOT / "src"
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
from owner_voice_pet.speech_models import (
|
||||||
|
DEFAULT_STT_DIR,
|
||||||
|
DEFAULT_STT_URL,
|
||||||
|
DEFAULT_VAD_URL,
|
||||||
|
check_speech_models,
|
||||||
|
write_default_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Download local speech models for owner_voice_pet.")
|
||||||
|
parser.add_argument("--dir", default="models", help="Target model directory. Default: models")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Re-download and replace existing model files")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
target = Path(args.dir)
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
download_vad(target, force=args.force)
|
||||||
|
download_stt(target, force=args.force)
|
||||||
|
manifest = write_default_manifest(target)
|
||||||
|
status = check_speech_models(target, require_sherpa=False)
|
||||||
|
print(f"models_dir={target}")
|
||||||
|
print(f"manifest={manifest}")
|
||||||
|
print(f"missing_files={list(status.missing_files)}")
|
||||||
|
return 0 if not status.missing_files else 1
|
||||||
|
|
||||||
|
|
||||||
|
def download_vad(models_dir: Path, *, force: bool = False) -> Path:
|
||||||
|
output = models_dir / "vad" / "silero_vad.onnx"
|
||||||
|
if output.exists() and not force:
|
||||||
|
print(f"skip existing {output}")
|
||||||
|
return output
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
download_file(DEFAULT_VAD_URL, output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def download_stt(models_dir: Path, *, force: bool = False) -> Path:
|
||||||
|
output_dir = models_dir / "stt" / DEFAULT_STT_DIR
|
||||||
|
if output_dir.exists() and any(output_dir.iterdir()) and not force:
|
||||||
|
print(f"skip existing {output_dir}")
|
||||||
|
return output_dir
|
||||||
|
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
archive = Path(tmp) / f"{DEFAULT_STT_DIR}.tar.bz2"
|
||||||
|
try:
|
||||||
|
download_file(DEFAULT_STT_URL, archive)
|
||||||
|
extract_tar_bz2(archive, output_dir.parent)
|
||||||
|
return output_dir
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
print(f"download/extract attempt {attempt} failed: {exc}")
|
||||||
|
if output_dir.exists():
|
||||||
|
shutil.rmtree(output_dir)
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
return output_dir
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url: str, output: Path) -> None:
|
||||||
|
tmp_output = output.with_suffix(output.suffix + ".part")
|
||||||
|
print(f"download {url}")
|
||||||
|
if shutil.which("curl"):
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-L",
|
||||||
|
"--fail",
|
||||||
|
"--retry",
|
||||||
|
"3",
|
||||||
|
"--connect-timeout",
|
||||||
|
"20",
|
||||||
|
"--max-time",
|
||||||
|
"600",
|
||||||
|
"-o",
|
||||||
|
str(tmp_output),
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with urllib.request.urlopen(url, timeout=600) as response, tmp_output.open("wb") as handle:
|
||||||
|
shutil.copyfileobj(response, handle)
|
||||||
|
tmp_output.replace(output)
|
||||||
|
print(f"wrote {output} ({output.stat().st_size} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tar_bz2(archive: Path, target_dir: Path) -> None:
|
||||||
|
with tarfile.open(archive, "r:bz2") as tar:
|
||||||
|
members = tar.getmembers()
|
||||||
|
resolved_target = target_dir.resolve()
|
||||||
|
for member in members:
|
||||||
|
destination = (target_dir / member.name).resolve()
|
||||||
|
if resolved_target != destination and resolved_target not in destination.parents:
|
||||||
|
raise RuntimeError(f"refusing unsafe archive member: {member.name}")
|
||||||
|
tar.extractall(target_dir, members=members)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -12,6 +12,7 @@ from .conversation import ConversationContext
|
|||||||
from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
|
from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
|
||||||
from .models import AudioFrame, ProviderError
|
from .models import AudioFrame, ProviderError
|
||||||
from .pipeline import VoicePipeline
|
from .pipeline import VoicePipeline
|
||||||
|
from .speech_models import check_speech_models, model_status_errors
|
||||||
from .stt import MetadataSttProvider
|
from .stt import MetadataSttProvider
|
||||||
from .transport import MemoryAudioTransport
|
from .transport import MemoryAudioTransport
|
||||||
from .tts import SineTtsProvider
|
from .tts import SineTtsProvider
|
||||||
@@ -27,6 +28,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
subparsers.add_parser("acceptance", help="Run deterministic end-to-end pipeline acceptance")
|
subparsers.add_parser("acceptance", help="Run deterministic end-to-end pipeline acceptance")
|
||||||
subparsers.add_parser("validate-assets", help="Validate project pet assets")
|
subparsers.add_parser("validate-assets", help="Validate project pet assets")
|
||||||
subparsers.add_parser("security-check", help="Scan tracked files for leaked API keys")
|
subparsers.add_parser("security-check", help="Scan tracked files for leaked API keys")
|
||||||
|
model_check = subparsers.add_parser("model-check", help="Validate local speech model files")
|
||||||
|
model_check.add_argument("--models-dir", default=None, help="Speech models directory. Defaults to .env or models")
|
||||||
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
|
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
|
||||||
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
|
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
|
||||||
smoke.add_argument("--no-stream", action="store_true")
|
smoke.add_argument("--no-stream", action="store_true")
|
||||||
@@ -46,6 +49,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"llm_stream": config.llm_stream,
|
"llm_stream": config.llm_stream,
|
||||||
"llm_api_key_present": bool(config.llm_api_key),
|
"llm_api_key_present": bool(config.llm_api_key),
|
||||||
"asset_dir": str(config.asset_dir),
|
"asset_dir": str(config.asset_dir),
|
||||||
|
"speech_models_dir": str(config.speech_models_dir),
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
@@ -63,6 +67,16 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(json.dumps({"secret_leaks": leaks, "valid": not leaks}, ensure_ascii=False, sort_keys=True))
|
print(json.dumps({"secret_leaks": leaks, "valid": not leaks}, ensure_ascii=False, sort_keys=True))
|
||||||
return 1 if leaks else 0
|
return 1 if leaks else 0
|
||||||
|
|
||||||
|
if args.command == "model-check":
|
||||||
|
config = AppConfig.from_dotenv(args.env_file)
|
||||||
|
models_dir = Path(args.models_dir) if args.models_dir else config.speech_models_dir
|
||||||
|
status = check_speech_models(models_dir, require_sherpa=True)
|
||||||
|
errors = model_status_errors(status)
|
||||||
|
data = status.to_json()
|
||||||
|
data["errors"] = [str(error) for error in errors]
|
||||||
|
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||||
|
return 1 if errors else 0
|
||||||
|
|
||||||
if args.command == "acceptance":
|
if args.command == "acceptance":
|
||||||
result = run_acceptance()
|
result = run_acceptance()
|
||||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
@@ -84,6 +98,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
audio_output_device=config.audio_output_device,
|
audio_output_device=config.audio_output_device,
|
||||||
asset_dir=config.asset_dir,
|
asset_dir=config.asset_dir,
|
||||||
log_dir=config.log_dir,
|
log_dir=config.log_dir,
|
||||||
|
speech_models_dir=config.speech_models_dir,
|
||||||
context_max_messages=config.context_max_messages,
|
context_max_messages=config.context_max_messages,
|
||||||
context_max_chars=config.context_max_chars,
|
context_max_chars=config.context_max_chars,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AppConfig:
|
|||||||
audio_output_device: str | None = None
|
audio_output_device: str | None = None
|
||||||
asset_dir: Path = Path("assets/pet")
|
asset_dir: Path = Path("assets/pet")
|
||||||
log_dir: Path = Path("logs")
|
log_dir: Path = Path("logs")
|
||||||
|
speech_models_dir: Path = Path("models")
|
||||||
context_max_messages: int = 12
|
context_max_messages: int = 12
|
||||||
context_max_chars: int = 12000
|
context_max_chars: int = 12000
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ class AppConfig:
|
|||||||
audio_output_device=get("AUDIO_OUTPUT_DEVICE"),
|
audio_output_device=get("AUDIO_OUTPUT_DEVICE"),
|
||||||
asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"),
|
asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"),
|
||||||
log_dir=Path(get("LOG_DIR", "logs") or "logs"),
|
log_dir=Path(get("LOG_DIR", "logs") or "logs"),
|
||||||
|
speech_models_dir=Path(get("SPEECH_MODELS_DIR", "models") or "models"),
|
||||||
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
|
context_max_messages=int(get("CONTEXT_MAX_MESSAGES", "12") or "12"),
|
||||||
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
|
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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-zh-14M-2023-02-23.tar.bz2"
|
||||||
|
)
|
||||||
|
DEFAULT_STT_DIR = "sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23"
|
||||||
|
|
||||||
|
REQUIRED_MODEL_FILES = (
|
||||||
|
"vad/silero_vad.onnx",
|
||||||
|
f"stt/{DEFAULT_STT_DIR}/tokens.txt",
|
||||||
|
f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx",
|
||||||
|
f"stt/{DEFAULT_STT_DIR}/decoder-epoch-99-avg-1.onnx",
|
||||||
|
f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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": {
|
||||||
|
"vad": DEFAULT_VAD_URL,
|
||||||
|
"stt": DEFAULT_STT_URL,
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"vad": {
|
||||||
|
"type": "silero-vad",
|
||||||
|
"path": "vad/silero_vad.onnx",
|
||||||
|
},
|
||||||
|
"stt": {
|
||||||
|
"type": "sherpa-onnx-streaming-transducer",
|
||||||
|
"model_dir": f"stt/{DEFAULT_STT_DIR}",
|
||||||
|
"tokens": f"stt/{DEFAULT_STT_DIR}/tokens.txt",
|
||||||
|
"encoder": f"stt/{DEFAULT_STT_DIR}/encoder-epoch-99-avg-1.int8.onnx",
|
||||||
|
"decoder": f"stt/{DEFAULT_STT_DIR}/decoder-epoch-99-avg-1.onnx",
|
||||||
|
"joiner": f"stt/{DEFAULT_STT_DIR}/joiner-epoch-99-avg-1.int8.onnx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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 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",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status.missing_files:
|
||||||
|
errors.append(
|
||||||
|
ProviderError(
|
||||||
|
ErrorCode.STT_MODEL_MISSING,
|
||||||
|
"missing speech model files: " + ", ".join(status.missing_files),
|
||||||
|
False,
|
||||||
|
"speech-models",
|
||||||
|
"model-check",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return errors
|
||||||
@@ -2,10 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from owner_voice_pet.cli import main
|
from owner_voice_pet.cli import main
|
||||||
|
from owner_voice_pet.speech_models import REQUIRED_MODEL_FILES, write_default_manifest
|
||||||
|
|
||||||
|
|
||||||
class CliAcceptanceTests(unittest.TestCase):
|
class CliAcceptanceTests(unittest.TestCase):
|
||||||
@@ -45,6 +49,28 @@ class CliAcceptanceTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertEqual(data["secret_leaks"], [])
|
self.assertEqual(data["secret_leaks"], [])
|
||||||
|
|
||||||
|
def test_model_check_reports_manifest_and_files(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
write_default_manifest(root)
|
||||||
|
for relative in REQUIRED_MODEL_FILES:
|
||||||
|
path = root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"placeholder")
|
||||||
|
with patch("importlib.util.find_spec", return_value=object()):
|
||||||
|
code, data = self.call("model-check", "--models-dir", str(root))
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["missing_files"], [])
|
||||||
|
|
||||||
|
def test_model_check_reports_missing_files(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with patch("importlib.util.find_spec", return_value=object()):
|
||||||
|
code, data = self.call("model-check", "--models-dir", tmp)
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertFalse(data["ok"])
|
||||||
|
self.assertTrue(data["missing_files"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop")
|
self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop")
|
||||||
self.assertEqual(config.llm_api_key, "secret-value")
|
self.assertEqual(config.llm_api_key, "secret-value")
|
||||||
self.assertEqual(config.llm_model, "test-model")
|
self.assertEqual(config.llm_model, "test-model")
|
||||||
|
self.assertEqual(str(config.speech_models_dir), "models")
|
||||||
self.assertTrue(config.llm_stream)
|
self.assertTrue(config.llm_stream)
|
||||||
self.assertEqual(config.validate_basic(), [])
|
self.assertEqual(config.validate_basic(), [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user