mirror of
https://github.com/Nighthawk42/mOrpheus.git
synced 2026-08-30 09:22:26 +00:00
370 lines
19 KiB
Python
370 lines
19 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
mOrpheus - A Voice Assistant Framework
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
import time
|
|
import threading
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
# --- Rich Imports ---
|
|
import numpy as np
|
|
from rich.console import Console
|
|
from rich.status import Status
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
# --- Core Manager Imports ---
|
|
try:
|
|
from modules.config_manager import load_config, get_setting, ConfigError
|
|
from modules.log_manager import setup_logging, logger # Use the logger configured by log_manager
|
|
from modules.performance_monitor import PerformanceMonitor
|
|
from modules.audio_manager import AudioManager, AudioError
|
|
from modules.stt_manager import STTManager, STTError, EXPECTED_SAMPLE_RATE as STT_SAMPLE_RATE
|
|
from modules.llm_manager import LLMManager, LLMError
|
|
from modules.tts_manager import TTSManager, TTSError, SNAC_EXPECTED_RATE as TTS_SAMPLE_RATE
|
|
from modules.hotword_manager import HotwordManager, HotwordError
|
|
except ImportError as e:
|
|
print(f"FATAL: Failed to import core modules: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# --- Global Console Object ---
|
|
# Use this for printing user interaction (You/Assistant) separately from logs
|
|
console = Console()
|
|
|
|
class VirtualAssistant:
|
|
"""
|
|
Orchestrates the voice assistant's components and main interaction loop.
|
|
"""
|
|
def __init__(self, config_path: Optional[str] = None):
|
|
"""Initializes all managers and loads configuration."""
|
|
# --- Config & Logging First ---
|
|
# Wrap initial setup for better error reporting if logging fails
|
|
try:
|
|
self._config_data = load_config(config_path)
|
|
log_level = get_setting("general.log_level", "INFO")
|
|
# Setup logging (will use RichHandler for console via log_manager)
|
|
setup_logging(log_level=log_level)
|
|
except ConfigError as e:
|
|
print(f"FATAL: Configuration Error: {e}", file=sys.stderr); raise
|
|
except Exception as e:
|
|
print(f"FATAL: Initial config/logging error: {e}", file=sys.stderr); raise ConfigError(f"Failed setup: {e}") from e
|
|
|
|
# --- Log Startup Info ---
|
|
logger.info("-" * 50)
|
|
logger.info("Initializing mOrpheus Virtual Assistant...")
|
|
logger.info("Configuration loaded.")
|
|
|
|
# --- Initialize Managers ---
|
|
self.performance_monitor: Optional[PerformanceMonitor] = None
|
|
self.audio_manager: Optional[AudioManager] = None
|
|
# ... (rest are similar)
|
|
self.stt_manager: Optional[STTManager] = None; self.llm_manager: Optional[LLMManager] = None
|
|
self.tts_manager: Optional[TTSManager] = None; self.hotword_manager: Optional[HotwordManager] = None
|
|
try:
|
|
self.performance_monitor = PerformanceMonitor()
|
|
self.audio_manager = AudioManager()
|
|
self.stt_manager = STTManager(performance_monitor=self.performance_monitor)
|
|
self.llm_manager = LLMManager(performance_monitor=self.performance_monitor)
|
|
self.tts_manager = TTSManager(performance_monitor=self.performance_monitor)
|
|
self.hotword_manager = HotwordManager(performance_monitor=self.performance_monitor)
|
|
# Store interaction settings
|
|
self.interaction_mode: str = get_setting("general.interaction_mode", "push_to_talk")
|
|
self.post_response_delay: float = get_setting("general.post_response_delay_sec", 0.5)
|
|
self.is_vad_enabled: bool = get_setting("audio.vad.enabled", False) # Store VAD status
|
|
|
|
# --- Log Key Settings Using Rich Panel ---
|
|
stt_model = get_setting("stt.model_size", "N/A")
|
|
llm_model = get_setting("llm.chat.model", "N/A")
|
|
tts_model = get_setting("tts.model", "N/A")
|
|
hotword_status = "Enabled" if self.hotword_manager.is_enabled else "Disabled"
|
|
if self.hotword_manager.is_enabled:
|
|
hotwords = get_setting("hotword.models", [])
|
|
hotword_status += f" ({', '.join(hotwords)})"
|
|
|
|
settings_summary = Text.assemble(
|
|
("STT Model: ", "bold cyan"), (stt_model, "white"), "\n",
|
|
("LLM Model: ", "bold cyan"), (llm_model, "white"), "\n",
|
|
("TTS Model: ", "bold cyan"), (tts_model, "white"), "\n",
|
|
("VAD: ", "bold cyan"), ("Enabled" if self.is_vad_enabled else "Disabled", "white"), "\n",
|
|
("Hotword: ", "bold cyan"), (hotword_status, "white"), "\n",
|
|
("Interaction: ", "bold cyan"), (self.interaction_mode, "white")
|
|
)
|
|
console.print(Panel(settings_summary, title="[bold]Configuration[/bold]", border_style="dim blue", expand=False))
|
|
|
|
logger.info("All managers initialized successfully.")
|
|
|
|
except (AudioError, STTError, TTSError, HotwordError) as e:
|
|
logger.critical("Failed manager init: %s", e, exc_info=True); raise
|
|
except Exception as e:
|
|
logger.critical("Unexpected init error: %s", e, exc_info=True); raise RuntimeError(f"Manager init failed: {e}") from e
|
|
|
|
# --- State Variables ---
|
|
self._running = threading.Event(); self._running.clear()
|
|
self._stop_called = False
|
|
|
|
def run(self):
|
|
"""Starts the main interaction loop of the assistant."""
|
|
if self._running.is_set(): logger.warning("Assistant already running."); return
|
|
if not all([self.audio_manager, ...]): logger.critical(...); return # Null checks
|
|
|
|
self._running.set(); self._stop_called = False
|
|
console.print(Panel(f"🎤 Assistant Activated | Mode: [cyan]{self.interaction_mode}[/cyan] | VAD: {'[green]On[/green]' if self.is_vad_enabled else '[yellow]Off[/yellow]'} ",
|
|
title="[bold green]mOrpheus[/bold green]", border_style="green", expand=False))
|
|
|
|
# Start hotword manager if applicable (VAD must be enabled for hotword/both modes)
|
|
if self.is_vad_enabled and (self.interaction_mode == "hotword" or self.interaction_mode == "both"):
|
|
if self.hotword_manager and self.hotword_manager.is_enabled:
|
|
logger.info("Starting hotword listener...")
|
|
self.hotword_manager.start()
|
|
else: logger.warning("Hotword interaction mode selected, but hotword is disabled/failed.")
|
|
elif (self.interaction_mode == "hotword" or self.interaction_mode == "both") and not self.is_vad_enabled:
|
|
logger.warning("Hotword/Both mode requires VAD to be enabled. Interaction may not work as expected.")
|
|
|
|
# --- Main Loop with Rich Status ---
|
|
try:
|
|
# Create a status object outside the loop to update it
|
|
with console.status("", spinner="dots") as status:
|
|
while self._running.is_set():
|
|
audio_data: Optional[np.ndarray] = None
|
|
try:
|
|
# 1. Wait for Activation
|
|
status.update("[bold cyan]Waiting for activation...[/bold cyan] (Press ENTER or say hotword)")
|
|
activated, activation_method = self._wait_for_activation()
|
|
if not activated or not self._running.is_set(): break
|
|
|
|
console.print(f"▶️ Activated via [yellow]{activation_method}[/yellow]!")
|
|
|
|
# --- Interaction Cycle ---
|
|
# 2. Record Audio
|
|
if self.is_vad_enabled:
|
|
status.update("🎙️ Listening... (VAD active)", spinner="simpleDotsScrolling")
|
|
audio_data = self.audio_manager.record_audio(target_sample_rate=STT_SAMPLE_RATE)
|
|
elif self.interaction_mode == "push_to_talk":
|
|
status.update("🔴 Recording... (Press [bold]ENTER[/bold] to stop)", spinner="recording")
|
|
if not self.audio_manager.start_async_recording(STT_SAMPLE_RATE):
|
|
logger.error("Failed to start async PTT recording.")
|
|
continue
|
|
stop_key_pressed = self._wait_for_stop_keypress(status) # Pass status to update
|
|
audio_data = self.audio_manager.stop_async_recording()
|
|
if not stop_key_pressed:
|
|
logger.warning("Recording stop not via keypress.")
|
|
if not self._running.is_set(): break
|
|
audio_data = None # Discard partial
|
|
else:
|
|
logger.error("Cannot record: VAD off & mode != push_to_talk.")
|
|
continue
|
|
|
|
if audio_data is None or audio_data.size == 0:
|
|
logger.warning("No audio captured."); continue
|
|
|
|
# Ensure managers exist (redundant check, but safe)
|
|
if not all([self.stt_manager, self.llm_manager, self.tts_manager, self.audio_manager]):
|
|
logger.critical("Manager missing mid-cycle!"); self._running.clear(); break
|
|
|
|
# 3. Transcribe
|
|
status.update("📝 Transcribing...", spinner="bouncingBar")
|
|
user_text, _, _ = self.stt_manager.transcribe_audio(audio_data, STT_SAMPLE_RATE)
|
|
if not user_text: logger.warning("Transcription empty."); continue
|
|
console.print(Text.assemble("👤 You: ", (user_text, "bright_blue"))) # Use Rich Text
|
|
|
|
# 4. LLM
|
|
status.update("🧠 Thinking...", spinner="line")
|
|
response_text = self.llm_manager.generate_chat_response(user_text)
|
|
if not response_text: logger.error("LLM failed."); continue
|
|
console.print(Text.assemble("🤖 Asst: ", (response_text, "green"))) # Use Rich Text
|
|
|
|
# 5. TTS
|
|
status.update("🔊 Synthesizing response...", spinner="material")
|
|
_filepath, audio_response, _rate = self.tts_manager.synthesize_speech(response_text)
|
|
if audio_response is None: logger.error("TTS failed."); continue
|
|
|
|
# 6. Play
|
|
status.update("💬 Speaking...", spinner="dots")
|
|
self.audio_manager.play_audio(audio_response, TTS_SAMPLE_RATE, wait_completion=True)
|
|
|
|
# 7. Delay
|
|
if self.post_response_delay > 0:
|
|
status.update(f"Cooldown ({self.post_response_delay}s)...", spinner="clock")
|
|
time.sleep(self.post_response_delay)
|
|
|
|
# Status will automatically reset to "Waiting..." at the start of the next loop
|
|
|
|
# --- Error Handling within Loop ---
|
|
# Log errors, but allow loop to continue to wait for next activation
|
|
except AudioError as e: logger.error("Audio Error in loop: %s", e); time.sleep(1)
|
|
except STTError as e: logger.error("STT Error in loop: %s", e); time.sleep(1)
|
|
except LLMError as e: logger.error("LLM Error in loop: %s", e); time.sleep(1)
|
|
except TTSError as e: logger.error("TTS Error in loop: %s", e); time.sleep(1)
|
|
except Exception as e:
|
|
logger.critical("Unexpected critical error in main loop: %s", e, exc_info=True)
|
|
if self.performance_monitor: self.performance_monitor.record_event("critical_errors")
|
|
self._running.clear(); break # Stop loop on critical errors
|
|
|
|
finally:
|
|
logger.info("Main loop terminated.")
|
|
self.stop() # Ensure cleanup happens
|
|
|
|
def _wait_for_activation(self) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Waits for hotword (if VAD enabled) or first PTT keypress.
|
|
Does NOT display prompts, only checks for activation signals.
|
|
"""
|
|
check_interval = 0.1
|
|
# VAD status is checked once in run() now
|
|
|
|
while self._running.is_set():
|
|
# --- Check Hotword ---
|
|
if self.is_vad_enabled and (self.interaction_mode == "hotword" or self.interaction_mode == "both") and \
|
|
self.hotword_manager and self.hotword_manager.is_enabled and self.hotword_manager.is_running:
|
|
detected_keyword = self.hotword_manager.get_detected_keyword()
|
|
if detected_keyword: return True, f"Hotword ({detected_keyword})"
|
|
|
|
# --- Check PTT Keypress ---
|
|
if self.interaction_mode == "push_to_talk" or self.interaction_mode == "both":
|
|
if self._check_for_keypress():
|
|
self._flush_stdin()
|
|
method = "Push-to-talk Start" if not self.is_vad_enabled and self.interaction_mode == "push_to_talk" else "Push-to-talk"
|
|
return True, method
|
|
|
|
try: time.sleep(check_interval)
|
|
except KeyboardInterrupt: logger.info("Interrupt during activation wait."); self._running.clear(); return False, None
|
|
return False, None
|
|
|
|
def _wait_for_stop_keypress(self, status: Status) -> bool:
|
|
"""
|
|
Waits for Enter press to stop async recording. Updates status.
|
|
Returns True if Enter was pressed, False otherwise (interrupt/error).
|
|
"""
|
|
# No need for separate debug log, status handles the prompt
|
|
# We don't use input() here to avoid interfering with status display.
|
|
# Rely on the non-blocking check instead.
|
|
check_interval = 0.05 # Check more frequently
|
|
while self._running.is_set():
|
|
if self._check_for_keypress():
|
|
logger.debug("Stop keypress detected.")
|
|
self._flush_stdin()
|
|
status.update("⏹️ Recording stopped.", spinner="dots") # Briefly update status
|
|
time.sleep(0.1) # Short pause to show status
|
|
return True # Stop key pressed successfully
|
|
|
|
try: time.sleep(check_interval)
|
|
except KeyboardInterrupt: logger.info("Interrupt while waiting for stop keypress."); self._running.clear(); return False
|
|
except Exception as e: logger.error("Error waiting for stop keypress: %s", e); self._running.clear(); return False
|
|
return False # Exited loop
|
|
|
|
|
|
def _check_for_keypress(self) -> bool:
|
|
"""Non-blocking check for keypress."""
|
|
# ... (Implementation remains the same) ...
|
|
try: import msvcrt; return msvcrt.kbhit()
|
|
except ImportError:
|
|
try: import select, sys; return select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], [])
|
|
except: return False # Catch potential errors like closed stdin
|
|
except Exception: return False
|
|
|
|
|
|
def _flush_stdin(self):
|
|
"""Flush any lingering characters from standard input."""
|
|
# ... (Implementation remains the same) ...
|
|
try: import termios, sys; termios.tcflush(sys.stdin, termios.TCIFLUSH)
|
|
except ImportError:
|
|
try:
|
|
import msvcrt
|
|
while msvcrt.kbhit():
|
|
msvcrt.getch()
|
|
except: pass # Ignore errors
|
|
except Exception: pass
|
|
|
|
|
|
def stop(self):
|
|
"""Signals the assistant to stop and cleans up resources."""
|
|
# ... (Implementation remains the same, uses self._stop_called flag) ...
|
|
if self._stop_called: return
|
|
if not self._running.is_set(): self._stop_called = True; return # If not running, just set flag
|
|
|
|
logger.info("Initiating mOrpheus shutdown...")
|
|
self._running.clear()
|
|
self._stop_called = True
|
|
|
|
logger.debug("Stopping Hotword Manager...")
|
|
if self.hotword_manager and self.hotword_manager.is_running: self.hotword_manager.stop()
|
|
|
|
logger.debug("Stopping Async Audio Recording (if active)...")
|
|
try:
|
|
if self.audio_manager:
|
|
if getattr(self.audio_manager, '_async_recording_thread', None) is not None:
|
|
self.audio_manager.stop_async_recording()
|
|
except Exception as e_stop_async: logger.warning("Ignoring error during async audio stop: %s", e_stop_async)
|
|
|
|
logger.debug("Stopping Audio Playback...")
|
|
if self.audio_manager: self.audio_manager.stop_playback()
|
|
|
|
logger.debug("Closing Network Sessions...")
|
|
if self.llm_manager: self.llm_manager.close_session()
|
|
if self.tts_manager: self.tts_manager.close_session()
|
|
|
|
if self.performance_monitor:
|
|
logger.info("-" * 50); self.performance_monitor.log_summary(); logger.info("-" * 50)
|
|
|
|
# Use console.print for final styled message
|
|
console.print(Panel("[bold red]mOrpheus Assistant Deactivated[/bold red]", border_style="red", expand=False))
|
|
logger.info("=" * 50)
|
|
|
|
|
|
# --- Main Execution ---
|
|
def main():
|
|
"""Parses arguments, initializes, and runs the VirtualAssistant."""
|
|
# ... (Argument parsing remains the same) ...
|
|
parser = argparse.ArgumentParser(description="Start the mOrpheus Virtual Assistant.")
|
|
parser.add_argument("-c", "--config", type=str, default=None, help="Path to config YAML.")
|
|
args = parser.parse_args()
|
|
|
|
assistant: Optional[VirtualAssistant] = None
|
|
exit_code = 0
|
|
try:
|
|
assistant = VirtualAssistant(config_path=args.config)
|
|
assistant.run() # Blocks until finished/interrupted
|
|
|
|
except ConfigError as e: # Catch config/init errors
|
|
# Logger might not be fully available, rely on print for critical startup failures
|
|
print(f"\nFATAL CONFIGURATION ERROR: {e}\n", file=sys.stderr)
|
|
exit_code = 1
|
|
except (AudioError, STTError, TTSError, HotwordError, RuntimeError) as e:
|
|
# Catch manager init errors
|
|
print(f"\nFATAL INITIALIZATION ERROR: {e}\n", file=sys.stderr)
|
|
# Try logging if available
|
|
try: logger.critical("Initialization Error: %s", e, exc_info=True)
|
|
except NameError: pass
|
|
exit_code = 1
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]User interrupt detected. Exiting.[/yellow]")
|
|
# Assistant.run() likely already called stop(), but call again if object exists
|
|
# if assistant and not getattr(assistant, '_stop_called', False): assistant.stop() # Redundant if finally works
|
|
exit_code = 0 # Normal exit for Ctrl+C
|
|
except Exception as e:
|
|
console.print(f"\n[bold red]UNEXPECTED FATAL ERROR:[/bold red]")
|
|
# Print traceback using rich console
|
|
console.print_exception(show_locals=False) # Set show_locals=True for more debug info
|
|
exit_code = 1
|
|
finally:
|
|
# Ensure stop is attempted if assistant was created, unless already called
|
|
if assistant and not getattr(assistant, '_stop_called', False):
|
|
logger.debug("Ensuring assistant stop called in main finally block.")
|
|
assistant.stop()
|
|
|
|
# Use console for final message if logger might be broken
|
|
if exit_code == 0:
|
|
console.print("[green]mOrpheus shutdown complete.[/green]")
|
|
else:
|
|
console.print("[red]mOrpheus shutdown with errors.[/red]")
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |