#!/usr/bin/env python3 """Diagnostic script: trigger the full voice pipeline with mocks and capture PIPELINE trace. Usage: python scripts/test_voice_pipeline.py Output: Prints each [PIPELINE] stage to stdout and writes pipeline_trace.log. The analysis section identifies the last completed stage and first missing stage, which is the confirmed break point for voice audio delivery. Run with LOG_LEVEL=DEBUG (the script sets it automatically). """ from __future__ import annotations import asyncio import os as _os import sys # Ensure project root is on sys.path so voice_loop is importable when running # from any working directory (e.g. python scripts/test_voice_pipeline.py). _PROJECT_ROOT = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))) if _PROJECT_ROOT not in sys.path: sys.path.insert(0, _PROJECT_ROOT) import io # noqa: E402 import logging # noqa: E402 import os # noqa: E402 import struct # noqa: E402 import wave # noqa: E402 from pathlib import Path # noqa: E402 from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 # Force DEBUG logging before any rex imports so pipeline logs are captured. os.environ["LOG_LEVEL"] = "DEBUG" os.environ.setdefault("REX_TESTING", "1") # ── log capture ──────────────────────────────────────────────────────────────── log_stream = io.StringIO() _fmt = logging.Formatter("%(asctime)s %(levelname)-8s %(name)s %(message)s") _cap = logging.StreamHandler(log_stream) _cap.setLevel(logging.DEBUG) _cap.setFormatter(_fmt) _out = logging.StreamHandler(sys.stdout) _out.setLevel(logging.DEBUG) _out.setFormatter(_fmt) root = logging.getLogger() root.setLevel(logging.DEBUG) root.addHandler(_cap) root.addHandler(_out) # ── pipeline stages we expect ────────────────────────────────────────────────── EXPECTED_STAGES = [ "llm_response_received", "tts_input_prepared", "tts_engine_called", "audio_data_returned", "audio_playback_initiated", "audio_playback_completed", ] def _make_wav(path: str) -> None: """Write a minimal valid WAV file so soundfile.read() succeeds.""" with wave.open(path, "w") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(22050) wf.writeframes(struct.pack(f"<{22050}h", *([0] * 22050))) async def run_mock_pipeline() -> str: """Run _process_conversation with all heavy deps mocked. Returns the captured log output as a string. """ import numpy as np # Import after env vars are set. from rex.config import AppConfig from voice_loop import AsyncRexAssistant config = AppConfig() # ── TTS mock ────────────────────────────────────────────────────────────── mock_tts = MagicMock() def fake_tts_to_file(*args, **kwargs): path = kwargs.get("file_path") if path: _make_wav(path) mock_tts.tts_to_file.side_effect = fake_tts_to_file # ── whisper mock ────────────────────────────────────────────────────────── mock_whisper = MagicMock() mock_whisper.transcribe.return_value = {"text": "hello rex"} # ── LLM mock ────────────────────────────────────────────────────────────── mock_llm = MagicMock() mock_llm.generate.return_value = "Hello! I am Rex, your AI assistant. How can I help?" # ── audio playback mock ─────────────────────────────────────────────────── # Patch winsound so we don't need a real audio device on Windows. mock_winsound = MagicMock() fake_audio = np.zeros(16000, dtype=np.float32) # Build assistant without running __init__ so we can inject mocks directly. assistant = AsyncRexAssistant.__new__(AsyncRexAssistant) assistant.config = config assistant.language_model = mock_llm assistant._tts = mock_tts assistant._whisper_model = mock_whisper assistant._wake_model = MagicMock() assistant._wake_keyword = "hey_rex" assistant._sample_rate = 16000 assistant.active_user = "james" assistant.user_voice_refs = {"james": None} assistant.plugins = {} assistant._wake_sound_path = None assistant._running = True assistant._state = "running" with ( patch("voice_loop.append_history_entry"), patch("voice_loop.export_transcript"), patch.object(assistant, "_record_audio", return_value=fake_audio), patch.object(assistant, "transcribe", new=AsyncMock(return_value="hello rex")), patch.object(assistant, "_play_wake_sound"), patch.dict("sys.modules", {"winsound": mock_winsound}), # Patch _get_tts so it returns our mock without loading XTTS. patch.object(type(assistant), "_get_tts", lambda self: mock_tts), ): print("\n" + "=" * 60) print("RUNNING VOICE PIPELINE — MOCK MODE") print("=" * 60 + "\n") try: await assistant._process_conversation() print("\n" + "=" * 60) print("PIPELINE RUN COMPLETE") print("=" * 60) except Exception as exc: print("\n" + "=" * 60) print(f"PIPELINE RAISED: {type(exc).__name__}: {exc}") print("=" * 60) return log_stream.getvalue() def analyze_trace(log_output: str) -> dict: """Return which expected stages were and were not logged.""" completed = [s for s in EXPECTED_STAGES if f"stage={s}" in log_output] missing = [s for s in EXPECTED_STAGES if f"stage={s}" not in log_output] return { "completed": completed, "missing": missing, "last_completed": completed[-1] if completed else None, "first_missing": missing[0] if missing else None, "all_passed": len(missing) == 0, } if __name__ == "__main__": log_output = asyncio.run(run_mock_pipeline()) trace_path = Path("pipeline_trace.log") trace_path.write_text(log_output, encoding="utf-8") result = analyze_trace(log_output) print("\n" + "=" * 60) print("PIPELINE TRACE ANALYSIS") print("=" * 60) print(f"Stages completed : {result['completed']}") print(f"Stages missing : {result['missing']}") print(f"Last completed : {result['last_completed']}") print(f"First missing : {result['first_missing']}") print(f"Full trace : {trace_path.resolve()}") if result["all_passed"]: print("\n✓ ALL 6 STAGES COMPLETED — pipeline is functioning with mocks.") else: print( f"\n✗ BREAK POINT detected: after '{result['last_completed']}' " f"before '{result['first_missing']}'" ) sys.exit(0 if result["all_passed"] else 1)