Files
Owner/src/owner_voice_pet/cli.py
T

301 lines
15 KiB
Python

from __future__ import annotations
import argparse
import json
import re
import subprocess
from pathlib import Path
from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
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 .real_live_check import run_real_live_check
from .runtime import build_live_runtime
from .simulation import run_simulated_live
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, SherpaOnnxKeywordWakeWordProvider
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")
simulate = subparsers.add_parser("simulate-live", help="Run live pipeline with simulated microphone frames")
simulate.add_argument("--turns", type=int, default=2, help="Number of simulated turns. Default: 2")
simulate.add_argument("--fixture", default=None, help="Replay simulated microphone frames from JSONL")
simulate.add_argument("--write-fixture", default=None, help="Write generated simulated microphone frames to JSONL")
real_check = subparsers.add_parser("real-live-check", help="Run generated-audio live check with real providers")
real_check.add_argument("--turns", type=int, default=2, help="Number of generated live turns. Default: 2")
real_check.add_argument("--voice", default="Tingting", help="macOS say voice used for generated microphone input")
real_check.add_argument("--wake-text", default="小杰小杰。", help="Generated wake utterance")
real_check.add_argument("--question", action="append", default=None, help="Generated user question; can be repeated")
real_check.add_argument("--no-playback", action="store_true", help="Synthesize but do not play generated TTS output")
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,
"realtime_transcript_enabled": config.realtime_transcript_enabled,
"llm_api_key_present": bool(config.llm_api_key),
"asset_dir": str(config.asset_dir),
"wake_provider": config.wake_provider,
"wake_keywords_file": str(config.wake_keywords_file) if config.wake_keywords_file else "",
"wake_kws_threshold": config.wake_kws_threshold,
"wake_kws_score": config.wake_kws_score,
"wake_ack_text": config.wake_ack_text,
"post_playback_drain_ms": config.post_playback_drain_ms,
"pipeline_mode": config.pipeline_mode,
"endpoint_mode": config.endpoint_mode,
"noise_filter_enabled": config.noise_filter_enabled,
"noise_filter_provider": config.noise_filter_provider,
"wake_denoise_enabled": config.wake_denoise_enabled,
"speaker_profile_ms": config.speaker_profile_ms,
"speaker_profile_min_ms": config.speaker_profile_min_ms,
"speaker_absent_ms": config.speaker_absent_ms,
"speaker_similarity_threshold": config.speaker_similarity_threshold,
"speaker_min_rms": config.speaker_min_rms,
"vad_provider": config.vad_provider,
"vad_threshold": config.vad_threshold,
"vad_min_duration_ms": config.vad_min_duration_ms,
"vad_end_silence_ms": config.vad_end_silence_ms,
"vad_no_speech_timeout_ms": config.vad_no_speech_timeout_ms,
"vad_max_recording_ms": config.vad_max_recording_ms,
"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),
"context_mode": config.context_mode,
},
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:
SherpaOnnxKeywordWakeWordProvider(
models_dir,
keyword=config.wake_word,
keywords_file=config.wake_keywords_file,
threshold=config.wake_kws_threshold,
score=config.wake_kws_score,
).load()
SherpaOnnxVadProvider(models_dir).load()
SherpaOnnxSttProvider(str(models_dir)).load()
SherpaOnnxDenoiserPreprocessor(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 == "simulate-live":
try:
data = run_simulated_live(
turns=args.turns,
fixture_path=args.fixture,
write_fixture=args.write_fixture,
)
except (ProviderError, ValueError) as exc:
print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False, sort_keys=True))
return 1
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 if data["success"] else 1
if args.command == "real-live-check":
config = AppConfig.from_dotenv(args.env_file)
try:
data = run_real_live_check(
config=config,
turns=args.turns,
voice=args.voice,
wake_text=args.wake_text,
questions=args.question,
play_audio=not args.no_playback,
)
except (ProviderError, ValueError) as exc:
print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False, sort_keys=True))
return 1
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 if data["success"] 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,
realtime_transcript_enabled=config.realtime_transcript_enabled,
audio_input_device=config.audio_input_device,
audio_output_device=config.audio_output_device,
asset_dir=config.asset_dir,
log_dir=config.log_dir,
wake_provider=config.wake_provider,
wake_keywords_file=config.wake_keywords_file,
wake_kws_threshold=config.wake_kws_threshold,
wake_kws_score=config.wake_kws_score,
wake_ack_text=config.wake_ack_text,
post_playback_drain_ms=config.post_playback_drain_ms,
pipeline_mode=config.pipeline_mode,
endpoint_mode=config.endpoint_mode,
noise_filter_enabled=config.noise_filter_enabled,
noise_filter_provider=config.noise_filter_provider,
wake_denoise_enabled=config.wake_denoise_enabled,
speaker_profile_ms=config.speaker_profile_ms,
speaker_profile_min_ms=config.speaker_profile_min_ms,
speaker_absent_ms=config.speaker_absent_ms,
speaker_similarity_threshold=config.speaker_similarity_threshold,
speaker_min_rms=config.speaker_min_rms,
vad_provider=config.vad_provider,
vad_threshold=config.vad_threshold,
vad_min_duration_ms=config.vad_min_duration_ms,
vad_end_silence_ms=config.vad_end_silence_ms,
vad_no_speech_timeout_ms=config.vad_no_speech_timeout_ms,
vad_max_recording_ms=config.vad_max_recording_ms,
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_mode=config.context_mode,
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