from __future__ import annotations import argparse import json import re import subprocess from pathlib import Path from .assets import validate_pet_assets from .config import AppConfig from .conversation import ConversationContext from .llm import MockLlmProvider, OpenAICompatibleLlmProvider from .models import AudioFrame, ProviderError from .pipeline import VoicePipeline from .runtime import build_live_runtime from .speech_models import check_speech_models, model_status_errors from .stt import MetadataSttProvider, SherpaOnnxSttProvider from .transport import MemoryAudioTransport, sounddevice_device_report from .tts import SineTtsProvider from .vad import EnergyVadProvider, SherpaOnnxVadProvider, VadRecorder from .wakeword import KeywordWakeWordProvider def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="owner-voice-pet") parser.add_argument("--env-file", default=".env", help="Path to .env config file") parser.add_argument("--show-config", action="store_true", help="Print non-secret config summary") subparsers = parser.add_subparsers(dest="command") 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") subparsers.add_parser("device-check", help="Validate local microphone and speaker availability") live = subparsers.add_parser("run-live", help="Run real repeated live voice conversation") live.add_argument("--once", action="store_true", help="Run one completed live turn and exit") 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") args = parser.parse_args(argv) if args.show_config: config = AppConfig.from_dotenv(args.env_file) print( json.dumps( { "wake_word": config.wake_word, "sample_rate": config.sample_rate, "channels": config.channels, "llm_base_url": config.llm_base_url, "llm_model": config.llm_model, "llm_api_style": config.llm_api_style, "llm_stream": config.llm_stream, "llm_api_key_present": bool(config.llm_api_key), "asset_dir": str(config.asset_dir), "speech_provider": config.speech_provider, "asr_model": config.asr_model, "tts_model": config.tts_model, "tts_voice": config.tts_voice, "speech_models_dir": str(config.speech_models_dir), }, ensure_ascii=False, sort_keys=True, ) ) return 0 if args.command == "validate-assets": infos = validate_pet_assets(AppConfig.from_dotenv(args.env_file).asset_dir) print(json.dumps({"assets": len(infos), "valid": True}, ensure_ascii=False, sort_keys=True)) return 0 if args.command == "security-check": leaks = find_secret_leaks(Path.cwd()) 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) provider_load_checked = False if not errors: try: SherpaOnnxVadProvider(models_dir).load() SherpaOnnxSttProvider(str(models_dir)).load() provider_load_checked = True except ProviderError as exc: errors.append(exc) data = status.to_json() data["errors"] = [str(error) for error in errors] data["provider_load_checked"] = provider_load_checked print(json.dumps(data, ensure_ascii=False, sort_keys=True)) return 1 if errors else 0 if args.command == "device-check": report = sounddevice_device_report() print(json.dumps(report, ensure_ascii=False, sort_keys=True)) return 0 if report["ok"] else 1 if args.command == "run-live": config = AppConfig.from_dotenv(args.env_file) try: summary = build_live_runtime(config).run(once=args.once) except ProviderError as exc: print(json.dumps({"ok": False, "code": exc.code.value, "message": exc.message}, ensure_ascii=False, sort_keys=True)) return 1 return 0 if summary.completed_turns > 0 or summary.interrupted else 1 if args.command == "acceptance": result = run_acceptance() print(json.dumps(result, ensure_ascii=False, sort_keys=True)) return 0 if result["success"] else 1 if args.command == "llm-smoke": config = AppConfig.from_dotenv(args.env_file) if args.no_stream: config = AppConfig( wake_word=config.wake_word, sample_rate=config.sample_rate, channels=config.channels, llm_base_url=config.llm_base_url, llm_api_key=config.llm_api_key, llm_model=config.llm_model, llm_api_style=config.llm_api_style, llm_stream=False, audio_input_device=config.audio_input_device, audio_output_device=config.audio_output_device, asset_dir=config.asset_dir, log_dir=config.log_dir, speech_provider=config.speech_provider, asr_model=config.asr_model, tts_model=config.tts_model, tts_voice=config.tts_voice, speech_models_dir=config.speech_models_dir, context_max_messages=config.context_max_messages, context_max_chars=config.context_max_chars, ) try: provider = OpenAICompatibleLlmProvider(config, timeout_s=30) messages = [ConversationContext().build_llm_messages()[0]] messages.append(__import__("owner_voice_pet.models", fromlist=["Message"]).Message("user", args.message, 1.0)) text = "".join(delta.text_delta for delta in provider.stream_reply(messages)).strip() print(json.dumps({"ok": bool(text), "reply_preview": text[:80]}, ensure_ascii=False, sort_keys=True)) return 0 if text else 1 except ProviderError as exc: print(json.dumps({"ok": False, "code": exc.code.value, "message": exc.message}, ensure_ascii=False, sort_keys=True)) return 1 parser.print_help() return 0 def run_acceptance() -> dict[str, object]: validate_pet_assets(AppConfig.from_dotenv().asset_dir) frames = [ AudioFrame(b"\x80\x80", 16000, 1, 0, 0, {"duration_ms": 20, "wake_word": "小杰小杰", "wake_confidence": 0.99}), AudioFrame(b"\xff\xff", 16000, 1, 20, 1, {"duration_ms": 20, "speech": True, "transcript": "你是谁"}), AudioFrame(b"\xff\xff", 16000, 1, 40, 2, {"duration_ms": 20, "speech": True}), AudioFrame(b"\x80\x80", 16000, 1, 60, 3, {"duration_ms": 20, "speech": False}), AudioFrame(b"\x80\x80", 16000, 1, 80, 4, {"duration_ms": 20, "speech": False}), ] transport = MemoryAudioTransport(frames) pipeline = VoicePipeline( transport=transport, wakeword=KeywordWakeWordProvider(), vad_recorder=VadRecorder(EnergyVadProvider(), min_duration_ms=40, end_silence_ms=40), stt=MetadataSttProvider(), context=ConversationContext(), llm=MockLlmProvider(["我是小杰桌宠,已经在线。"]), tts=SineTtsProvider(), ) pipeline.load() result = pipeline.run_once() return { "success": result.success, "transcript": result.transcript, "assistant_text": result.assistant_text, "played_segments": result.played_segments, "states": [state.value for state in result.states], } def find_secret_leaks(root: Path) -> list[str]: completed = subprocess.run(["git", "ls-files"], cwd=root, check=True, stdout=subprocess.PIPE, text=True) pattern = re.compile(r"(?:sk|tp)-[A-Za-z0-9_\\-]{16,}") leaks: list[str] = [] for rel in completed.stdout.splitlines(): path = root / rel if not path.exists(): continue if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}: continue try: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue if pattern.search(text): leaks.append(rel) return leaks