[依赖、模型和配置]:完成本地语音模型准备,包含.env配置、模型下载脚本和model-check

This commit is contained in:
mkbk
2026-06-17 19:34:29 +08:00
parent 40510325d9
commit e877cca00f
10 changed files with 323 additions and 7 deletions
+6
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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 = []
+119
View File
@@ -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())
+15
View File
@@ -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,
) )
+2
View File
@@ -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"),
) )
+142
View File
@@ -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
+26
View File
@@ -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()
+1
View File
@@ -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(), [])