diff --git a/.env.example b/.env.example index 3b3fcf4..854d9f8 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,9 @@ OWNER_AUDIO_INPUT_DEVICE= OWNER_AUDIO_OUTPUT_DEVICE= OWNER_ASSET_DIR=assets/pet 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 diff --git a/.gitignore b/.gitignore index 06a60a3..b3e2d90 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,12 @@ .env.* !.env.example .venv/ +models/ __pycache__/ *.py[cod] +*.aiff +*.wav +*.flac .pytest_cache/ .mypy_cache/ .ruff_cache/ diff --git a/openspec/changes/complete-live-repeat-voice-runtime/tasks.md b/openspec/changes/complete-live-repeat-voice-runtime/tasks.md index 2242bc1..f5126f6 100644 --- a/openspec/changes/complete-live-repeat-voice-runtime/tasks.md +++ b/openspec/changes/complete-live-repeat-voice-runtime/tasks.md @@ -10,12 +10,12 @@ ## 2. 依赖、模型和配置规划落地 -- [ ] 2.1 更新 `.gitignore` 忽略 `models/`、`.venv/`、TTS 临时音频和 Python 缓存;前置条件:OpenSpec 提交完成;验收标准:模型和本地密钥不会出现在 git status;测试要点:`git status --short` 不列出模型大文件;优先级:P0;预计:20 分钟。 -- [ ] 2.2 更新 `.env.example`,加入模型目录、上下文预算和 live runtime 相关配置;前置条件:配置字段确认;验收标准:用户复制后可填写 key 并运行;测试要点:配置加载测试覆盖默认值;优先级:P0;预计:30 分钟。 -- [ ] 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 分钟。 -- [ ] 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.1 更新 `.gitignore` 忽略 `models/`、`.venv/`、TTS 临时音频和 Python 缓存;前置条件:OpenSpec 提交完成;验收标准:模型和本地密钥不会出现在 git status;测试要点:`git status --short` 不列出模型大文件;优先级:P0;预计:20 分钟。 +- [x] 2.2 更新 `.env.example`,加入模型目录、上下文预算和 live runtime 相关配置;前置条件:配置字段确认;验收标准:用户复制后可填写 key 并运行;测试要点:配置加载测试覆盖默认值;优先级:P0;预计:30 分钟。 +- [x] 2.3 新增 `scripts/download_speech_models.py --dir models`;前置条件:确认模型来源;验收标准:脚本能创建模型目录、下载/解压模型、写入 manifest;测试要点:脚本参数解析和 manifest 测试通过;优先级:P0;预计:60 分钟。 +- [x] 2.4 在项目本地 `.venv` 安装 `sounddevice`、`numpy`、`sherpa-onnx`;前置条件:`python3.11` 可用;验收标准:`.venv/bin/python -c` 可导入三个依赖;测试要点:不污染全局环境;优先级:P0;预计:30 分钟。 +- [x] 2.5 下载模型到 `models/` 并运行 `model-check` 预备验证;前置条件:下载脚本完成;验收标准:模型文件存在且未进入 Git;测试要点:`git status --short` 不列出模型文件;优先级:P0;预计:60 分钟。 +- [x] 2.6 验证并提交“依赖、模型和配置”模块;前置条件:2.1 至 2.5 完成;验收标准:compileall、相关测试、security-check、OpenSpec strict 通过后 commit;测试要点:提交信息为中文格式;优先级:P0;预计:20 分钟。 ## 3. 本机音频 Transport 与设备检查 diff --git a/pyproject.toml b/pyproject.toml index 56f867f..bf19970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ authors = [{ name = "mkbk" }] 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"] test = [] diff --git a/scripts/download_speech_models.py b/scripts/download_speech_models.py new file mode 100644 index 0000000..af3538a --- /dev/null +++ b/scripts/download_speech_models.py @@ -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()) diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index 6a0be62..2476312 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -12,6 +12,7 @@ from .conversation import ConversationContext from .llm import MockLlmProvider, OpenAICompatibleLlmProvider from .models import AudioFrame, ProviderError from .pipeline import VoicePipeline +from .speech_models import check_speech_models, model_status_errors from .stt import MetadataSttProvider from .transport import MemoryAudioTransport 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("validate-assets", help="Validate project pet assets") 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.add_argument("--message", default="用一句中文回复:小杰在线。") 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_api_key_present": bool(config.llm_api_key), "asset_dir": str(config.asset_dir), + "speech_models_dir": str(config.speech_models_dir), }, ensure_ascii=False, 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)) 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": result = run_acceptance() 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, asset_dir=config.asset_dir, log_dir=config.log_dir, + speech_models_dir=config.speech_models_dir, context_max_messages=config.context_max_messages, context_max_chars=config.context_max_chars, ) diff --git a/src/owner_voice_pet/config.py b/src/owner_voice_pet/config.py index 884270e..8c4efe2 100644 --- a/src/owner_voice_pet/config.py +++ b/src/owner_voice_pet/config.py @@ -20,6 +20,7 @@ class AppConfig: audio_output_device: str | None = None asset_dir: Path = Path("assets/pet") log_dir: Path = Path("logs") + speech_models_dir: Path = Path("models") context_max_messages: int = 12 context_max_chars: int = 12000 @@ -44,6 +45,7 @@ class AppConfig: audio_output_device=get("AUDIO_OUTPUT_DEVICE"), asset_dir=Path(get("ASSET_DIR", "assets/pet") or "assets/pet"), 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_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"), ) diff --git a/src/owner_voice_pet/speech_models.py b/src/owner_voice_pet/speech_models.py new file mode 100644 index 0000000..c9d3de4 --- /dev/null +++ b/src/owner_voice_pet/speech_models.py @@ -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 diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py index 90438e1..8c45364 100644 --- a/tests/test_cli_acceptance.py +++ b/tests/test_cli_acceptance.py @@ -2,10 +2,14 @@ from __future__ import annotations import io import json +import tempfile import unittest 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.speech_models import REQUIRED_MODEL_FILES, write_default_manifest class CliAcceptanceTests(unittest.TestCase): @@ -45,6 +49,28 @@ class CliAcceptanceTests(unittest.TestCase): self.assertEqual(code, 0) 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__": unittest.main() diff --git a/tests/test_models_config.py b/tests/test_models_config.py index 97627ad..9ebf622 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -69,6 +69,7 @@ class ModelsConfigTests(unittest.TestCase): self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop") self.assertEqual(config.llm_api_key, "secret-value") self.assertEqual(config.llm_model, "test-model") + self.assertEqual(str(config.speech_models_dir), "models") self.assertTrue(config.llm_stream) self.assertEqual(config.validate_basic(), [])