diff --git a/.gitignore b/.gitignore index 0b46422..0d20b64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ *.pyc -mOrpheus.txt diff --git a/README.md b/README.md deleted file mode 100644 index bc9b213..0000000 --- a/README.md +++ /dev/null @@ -1,195 +0,0 @@ -# mOrpheus Virtual Assistant Demo - -This project implements the mOrpheus Virtual Assistant, which integrates speech recognition, text generation, and text-to-speech (TTS) synthesis. The assistant leverages several state-of-the-art components: - -- **Whisper** for speech recognition. -- **LM Studio API** for both text generation (chat) and text-to-speech synthesis. -- **SNAC-based decoder** to convert TTS token streams into PCM audio. - -## Features - -- **Speech Recognition:** - Captures audio from the microphone and transcribes it using the Whisper model. - -- **Text Generation:** - Uses LM Studio’s chat API to generate natural language responses based on transcribed input. - -- **Text-to-Speech:** - Converts the generated text into speech via LM Studio’s TTS API. The text is cleaned (removing newlines, markdown symbols, and emojis) before being sent to the TTS engine, and the resulting token stream is decoded using a SNAC-based decoder. - - -- **Audio Playback & Activation:** - Plays the generated audio and waits for the next activation. Long responses are segmentented then recombined at playback. The assistant supports both push‑to‑talk (keypress) and hotword (“Hey Assistant”) activation, or both simultaneously, as configured. - -## Requirements - -- Python 3.7+ -- [PyTorch](https://pytorch.org/) -- [Whisper](https://github.com/openai/whisper) -- [sounddevice](https://python-sounddevice.readthedocs.io/) -- [scipy](https://www.scipy.org/) -- [numpy](https://numpy.org/) -- [requests](https://docs.python-requests.org/) -- [PyYAML](https://pyyaml.org/) -- [Transformers](https://huggingface.co/transformers/) -- [SNAC](https://github.com/hubertsiuzdak/snac) - -## Setup - -1. **Clone the repository:** - - ```bash - git clone https://github.com/yourusername/morpheus-virtual-assistant.git - cd morpheus-virtual-assistant - ``` - -2. **Install the dependencies:** - - ```bash - pip install -r requirements.txt - ``` - - *Ensure that your `requirements.txt` includes all required packages.* - -3. **Configure the application:** - - - Create a `settings.yml` file in the project root. - - Populate it with your configuration details for Whisper, LM Studio API, audio settings, interaction mode, etc. - - Example `settings.yml`: - - ```yaml - # ------------------------------- - # Configuration for Whisper STT - # ------------------------------- - whisper: - model: "small.en" - sample_rate: 16000 - - # ------------------------------- - # Configuration for LM Studio (Chat & TTS) - # ------------------------------- - lm: - api_url: "http://127.0.0.1:1234/v1" - - chat: - endpoint: "/chat/completions" - model: "gemma-3-12b-it" - system_prompt: "You are a helpful assistant." - max_tokens: 256 - temperature: 0.7 - top_p: 0.9 - repetition_penalty: 1.1 - max_response_time: 10.0 - - tts: - endpoint: "/completions" - model: "orpheus-3b-ft.gguf@q2_k" - default_voice: "tara" - max_tokens: 4096 - temperature: 0.6 - top_p: 0.9 - repetition_penalty: 1.0 - speed: 1.0 - max_segment_duration: 20 - - # ------------------------------- - # TTS Audio Output Configuration - # ------------------------------- - tts: - sample_rate: 24000 - - # ------------------------------- - # Audio Device Configuration - # ------------------------------- - audio: - input_device: null - output_device: null - hotword_sample_rate: 16000 - - # ------------------------------- - # Voice Activity Detection (VAD) Configuration - # ------------------------------- - vad: - mode: 2 - frame_duration_ms: 30 - silence_threshold_ms: 1000 - min_record_time_ms: 2000 - - # ------------------------------- - # Hotword Detection Configuration - # ------------------------------- - hotword: - enabled: true - phrase: "Hey Assistant" - sensitivity: 0.7 - timeout_sec: 5 - retries: 3 - - # ------------------------------- - # Segmentation Configuration for TTS - # ------------------------------- - segmentation: - max_words: 60 - - # ------------------------------- - # Speech Quality Configuration - # ------------------------------- - speech: - normalize_audio: false - default_pitch: 0 - min_speech_confidence: 0.5 - max_retries: 3 - - # ------------------------------- - # Interaction Configuration - # ------------------------------- - interaction: - mode: "both" # Options: "push_to_talk", "hotword", or "both" - post_audio_delay: 0.5 - ``` - -4. **Run LM Studio** -Before activating the assistant you need to have LM Studio running both the LLM and Orpheus model as defined in the settings.yml in API mode. This is only accessibly in Power User or Developer Mode respectively. - -5. **Run the Assistant:** - - ```bash - python morpheus.py - ``` - - -## Suggested Models - -These are the models I tested with, your milage may vary with additional models. - -**LLM Models:** -- gemma-3-12b-it-GGUF/gemma-3-12b-it-Q3_K_L.gguf - -**Orpheus Models:** -- lex-au/Orpheus-3b-FT-Q2_K.gguf - Fastest inference (~50% faster tokens/sec than Q8_0). -- lex-au/Orpheus-3b-FT-Q4_K_M.gguf - Balanced quality/speed. -- lex-au/Orpheus-3b-FT-Q8_0.gguf - Original high-quality model. - -## Usage - -- **Activation:** - The assistant listens for activation either via a hotword ("Hey Assistant") or a push-to-talk keypress (or both, depending on your settings). -- **Speech Processing:** - It records your speech, transcribes it using Whisper, and generates a text response via LM Studio’s chat API. -- **TTS Synthesis:** - The response is cleaned to remove unwanted characters (e.g., emojis, newlines, markdown formatting) and then sent to LM Studio’s TTS API. The SNAC-based decoder converts the TTS token stream into PCM audio. -- **Audio Playback:** - The generated audio is played back, and a brief delay is applied before the assistant waits for the next activation. - -## Contributing - -Feel free to open issues or submit pull requests with improvements or bug fixes. I'm fairly new at this and feel this could see a lot of improvements. - -## License - -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. - -## Notice - -See the [NOTICE](NOTICE) file for details. diff --git a/modules/__init.py__ b/modules/__init.py__ new file mode 100644 index 0000000..e69de29 diff --git a/modules/audio.py b/modules/audio.py deleted file mode 100644 index d5255df..0000000 --- a/modules/audio.py +++ /dev/null @@ -1,88 +0,0 @@ -# modules/audio.py -import os -import time -import wave -import numpy as np -import sounddevice as sd -import webrtcvad -from scipy.io.wavfile import write as wav_write -from modules.logging import logger - -# Default parameters (can be overridden via configuration) -VAD_MODE = 2 -FRAME_DURATION_MS = 30 -SILENCE_THRESHOLD_MS = 1000 -MIN_RECORD_TIME_MS = 2000 -DEFAULT_MAX_WORDS_PER_SEGMENT = 60 - -def record_until_silence(sample_rate, device=None): - """ - Records audio until a period of silence is detected. - """ - try: - vad_inst = webrtcvad.Vad(VAD_MODE) - frame_length = int(sample_rate * FRAME_DURATION_MS / 1000) - silence_frames = int(SILENCE_THRESHOLD_MS / FRAME_DURATION_MS) - logger.info("Recording until %d ms of silence is detected...", SILENCE_THRESHOLD_MS) - recorded_frames = [] - consecutive_silence = 0 - start_time = time.time() - - with sd.InputStream(samplerate=sample_rate, channels=1, device=device, blocksize=frame_length) as stream: - while True: - frame, _ = stream.read(frame_length) - frame_int16 = (np.squeeze(frame) * 32767).astype(np.int16).tobytes() - is_speech = vad_inst.is_speech(frame_int16, sample_rate) - recorded_frames.append(frame) - if not is_speech: - consecutive_silence += 1 - else: - consecutive_silence = 0 - elapsed_ms = (time.time() - start_time) * 1000 - if consecutive_silence >= silence_frames and elapsed_ms > MIN_RECORD_TIME_MS: - logger.info("Silence detected; stopping recording (elapsed %.0f ms)", elapsed_ms) - break - - audio = np.concatenate(recorded_frames, axis=0) - return audio - except Exception as e: - logger.error("Error during audio recording: %s", str(e)) - raise - -def segment_text(text, max_words=DEFAULT_MAX_WORDS_PER_SEGMENT): - """ - Splits text into segments of at most max_words, attempting to split at sentence boundaries. - """ - words = text.split() - segments = [] - while len(words) > max_words: - segment = " ".join(words[:max_words]) - cutoff = max_words - for i, word in enumerate(segment.split()): - if word.endswith((".", "!", "?")): - cutoff = i + 1 - segments.append(" ".join(words[:cutoff]).strip()) - words = words[cutoff:] - if words: - segments.append(" ".join(words).strip()) - return segments - -def combine_audio_files(file_list, output_file): - """ - Combines multiple WAV files into one. - """ - if not file_list: - return None - try: - with wave.open(file_list[0], "rb") as wf: - params = wf.getparams() - with wave.open(output_file, "wb") as out_wf: - out_wf.setparams(params) - for f in file_list: - with wave.open(f, "rb") as wf: - out_wf.writeframes(wf.readframes(wf.getnframes())) - logger.info("Combined audio written to %s", output_file) - return output_file - except Exception as e: - logger.error("Error combining audio files: %s", str(e)) - raise diff --git a/modules/audio_manager.py b/modules/audio_manager.py new file mode 100644 index 0000000..68afbfe --- /dev/null +++ b/modules/audio_manager.py @@ -0,0 +1,382 @@ +# modules/audio_manager.py + +import time +import queue +import threading +from typing import Optional, Tuple, List, Dict, Any + +import numpy as np +import sounddevice as sd +import webrtcvad +import wave +from pathlib import Path # Added for save_wave directory check +from scipy.signal import resample_poly # For potential resampling if needed + +# Use logger and config manager +try: + from .log_manager import logger + from .config_manager import get_setting +except ImportError: + import logging + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log/config managers. Using defaults.") + # Mock get_setting for standalone testing if needed + def get_setting(key_path: str, default: Any = None) -> Any: + defaults = { + "audio.input_device": None, "audio.output_device": None, + "audio.vad.enabled": True, "audio.vad.sample_rate": 16000, + "audio.vad.frame_duration_ms": 30, "audio.vad.aggressiveness": 2, + "audio.vad.silence_duration_ms": 1200, "audio.vad.min_record_duration_ms": 500, + "tts.normalize_volume": True, "tts.sample_rate": 24000, + } + keys = key_path.split('.') + val = defaults + try: + for k in keys: val = val[k] + return val + except (KeyError, TypeError): return default + + +class AudioError(Exception): + """Custom exception for audio-related errors.""" + pass + + +class AudioManager: + """Handles audio recording (with VAD) and playback.""" + + # VAD requires specific sample rates + _VAD_SUPPORTED_RATES = {8000, 16000, 32000, 48000} + + def __init__(self): + logger.info("Initializing AudioManager...") + self._input_device_id: Optional[int] = self._get_device_id("input") + self._output_device_id: Optional[int] = self._get_device_id("output") + + # VAD specific setup + self._vad_enabled: bool = get_setting("audio.vad.enabled", True) + if self._vad_enabled: + self._vad_sample_rate: int = get_setting("audio.vad.sample_rate", 16000) + self._vad_frame_duration: int = get_setting("audio.vad.frame_duration_ms", 30) + self._vad_aggressiveness: int = get_setting("audio.vad.aggressiveness", 2) + self._vad_silence_ms: int = get_setting("audio.vad.silence_duration_ms", 1200) + self._vad_min_record_ms: int = get_setting("audio.vad.min_record_duration_ms", 500) + + if self._vad_sample_rate not in self._VAD_SUPPORTED_RATES: + raise AudioError(f"VAD sample rate {self._vad_sample_rate}Hz not supported.") + if self._vad_frame_duration not in [10, 20, 30]: + raise AudioError("VAD frame duration must be 10, 20, or 30 ms.") + if not 0 <= self._vad_aggressiveness <= 3: + raise AudioError("VAD aggressiveness must be between 0 and 3.") + + try: + self._vad = webrtcvad.Vad(self._vad_aggressiveness) + logger.info("VAD initialized (Rate: %dHz, Frame: %dms, Silence: %dms, Aggressiveness: %d)", + self._vad_sample_rate, self._vad_frame_duration, self._vad_silence_ms, self._vad_aggressiveness) + except Exception as e: + logger.error("Failed to initialize WebRTC VAD: %s", e, exc_info=True) + raise AudioError("Failed to initialize WebRTC VAD") from e + else: + self._vad = None + logger.info("VAD is disabled via configuration.") + + self._normalize_playback: bool = get_setting("tts.normalize_volume", True) + self._list_devices() # Log available devices for debugging + + # --- State for Async Recording --- + self._async_recording_thread: Optional[threading.Thread] = None + self._async_recording_stop_event = threading.Event() + self._async_audio_buffer: List[np.ndarray] = [] + self._async_recording_active = threading.Lock() # Lock to manage access/state + self._async_sample_rate: int = 16000 # Default, set during start + + def _get_device_id(self, device_type: str) -> Optional[int]: + """Gets the configured device ID, handling 'null' for default.""" + device_setting = get_setting(f"audio.{device_type}_device", None) + if device_setting is None or str(device_setting).lower() == 'null': + logger.info("Using default %s audio device.", device_type) + return None + try: + devices = sd.query_devices() + device_id = int(device_setting) + if 0 <= device_id < len(devices): + dev_info = devices[device_id] + if device_type == "input" and dev_info.get('max_input_channels', 0) > 0: return device_id + elif device_type == "output" and dev_info.get('max_output_channels', 0) > 0: return device_id + else: + logger.warning("Device ID %d (%s) does not support %s. Using default.", device_id, dev_info.get('name'), device_type) + return None + else: + logger.warning("Invalid audio device ID '%s'. Must be between 0 and %d. Using default.", device_setting, len(devices) -1) + return None + except (ValueError, TypeError): + logger.warning("Invalid audio device ID format '%s' for %s device. Using default.", device_setting, device_type) + return None + except sd.PortAudioError as e: + logger.error("PortAudio error querying devices: %s. Using default devices.", e) + return None + except Exception as e: + logger.error("Unexpected error getting device ID: %s. Using default.", e) + return None + + def _list_devices(self): + """Logs available audio devices and checks settings.""" + try: + devices = sd.query_devices() + logger.debug("Available audio devices:\n%s", devices) + + default_input_info = None; default_output_info = None + try: default_input_info = sd.query_devices(kind='input') + except Exception as e_in: logger.warning("Error querying default input device: %s", e_in) + try: default_output_info = sd.query_devices(kind='output') + except Exception as e_out: logger.warning("Error querying default output device: %s", e_out) + + input_dev_idx: Any = 'Default'; input_dev_name = "Default (Not Found)" + if self._input_device_id is not None: + input_dev_idx = self._input_device_id + if 0 <= input_dev_idx < len(devices): input_dev_name = devices[input_dev_idx].get('name', 'Unknown') + else: input_dev_name = f"Invalid Index ({input_dev_idx})" + elif default_input_info: + input_dev_idx = default_input_info.get('index', 'Default') + input_dev_name = default_input_info.get('name', 'Default Input') + + output_dev_idx: Any = 'Default'; output_dev_name = "Default (Not Found)" + if self._output_device_id is not None: + output_dev_idx = self._output_device_id + if 0 <= output_dev_idx < len(devices): output_dev_name = devices[output_dev_idx].get('name', 'Unknown') + else: output_dev_name = f"Invalid Index ({output_dev_idx})" + elif default_output_info: + output_dev_idx = default_output_info.get('index', 'Default') + output_dev_name = default_output_info.get('name', 'Default Output') + + logger.info("Selected Input Device: %s - %s", str(input_dev_idx), input_dev_name) + logger.info("Selected Output Device: %s - %s", str(output_dev_idx), output_dev_name) + + vad_rate = self._vad_sample_rate if self._vad_enabled else 16000 + try: + sd.check_input_settings(device=self._input_device_id, channels=1, samplerate=vad_rate) + logger.debug("Input device settings check passed (Rate: %d Hz).", vad_rate) + except (ValueError, sd.PortAudioError) as e: + logger.warning("Input device settings check failed for device %s: %s", str(input_dev_idx), e) + + tts_rate = get_setting("tts.sample_rate", 24000) + try: + sd.check_output_settings(device=self._output_device_id, channels=1, samplerate=tts_rate) + logger.debug("Output device settings check passed (Rate: %d Hz).", tts_rate) + except (ValueError, sd.PortAudioError) as e: + logger.warning("Output device settings check failed for device %s: %s", str(output_dev_idx), e) + + except sd.PortAudioError as e: logger.error("PortAudio error during device listing/checking: %s", e) + except Exception as e: logger.error("Error listing or checking audio devices: %s", e, exc_info=True) + + def record_audio( + self, + target_sample_rate: int, + duration_seconds: Optional[float] = None, + ) -> Optional[np.ndarray]: + """ + Records audio. Uses VAD if enabled and duration is None. + Uses fixed duration if duration_seconds is provided. + For non-VAD PTT, use start/stop_async_recording methods. + """ + if duration_seconds is not None: + record_sample_rate = target_sample_rate + num_frames = int(duration_seconds * record_sample_rate) + logger.info("Starting fixed duration recording: %.2f seconds at %d Hz...", duration_seconds, record_sample_rate) + try: + audio_data = sd.rec(frames=num_frames, samplerate=record_sample_rate, channels=1, dtype='float32', device=self._input_device_id) + sd.wait() + logger.info("Fixed duration recording finished.") + return audio_data.flatten() if audio_data.size > 0 else None + except sd.PortAudioError as e: raise AudioError(f"Audio recording failed: {e}") from e + except Exception as e: raise AudioError(f"Unexpected recording error: {e}") from e + + elif self._vad_enabled and self._vad: + logger.info("Starting VAD recording (target rate: %d Hz, VAD rate: %d Hz)", target_sample_rate, self._vad_sample_rate) + audio_data_vad_rate = self._record_with_vad() + if audio_data_vad_rate is None or audio_data_vad_rate.size == 0: logger.warning("VAD recording captured no audio."); return None + if self._vad_sample_rate != target_sample_rate: + logger.debug("Resampling VAD audio from %d Hz to %d Hz", self._vad_sample_rate, target_sample_rate) + try: + audio_data = resample_poly(audio_data_vad_rate, target_sample_rate, self._vad_sample_rate).astype(np.float32) + logger.debug("Resampling complete. New length: %d samples", len(audio_data)) + return audio_data.flatten() + except Exception as e: raise AudioError("Failed to resample recorded audio") from e + else: + return audio_data_vad_rate.flatten() + else: + raise AudioError("Cannot record: Specify duration_seconds or use start/stop_async_recording for non-VAD PTT.") + + def _record_with_vad(self) -> Optional[np.ndarray]: + """Internal helper for VAD-based recording. Returns float32 at VAD rate.""" + frames_per_buffer = int(self._vad_sample_rate * self._vad_frame_duration / 1000) + vad_bytes_per_frame = frames_per_buffer * 2 + silence_frames_needed = int(self._vad_silence_ms / self._vad_frame_duration) + min_record_frames = int(self._vad_min_record_ms / self._vad_frame_duration) + recorded_frames_bytes: List[bytes] = [] + consecutive_silence_frames = 0; triggered = False; total_frames = 0 + start_time = time.monotonic() + audio_queue: queue.Queue[Optional[bytes]] = queue.Queue(maxsize=50) + + def audio_callback(indata: np.ndarray, frames: int, time_info: Any, status: sd.CallbackFlags): + if status: logger.warning("Sounddevice callback status: %s", str(status)) + try: + if isinstance(indata, np.ndarray) and indata.dtype == np.int16: audio_queue.put_nowait(indata.tobytes()) + elif isinstance(indata, np.ndarray): logger.error("Callback wrong dtype: %s", indata.dtype) + else: logger.error("Callback non-numpy data: %s", type(indata)) + except queue.Full: logger.warning("Audio queue full in VAD callback.") + except Exception as cb_e: logger.error("Error in VAD audio callback: %s", cb_e) + + logger.info("Listening... (Silence threshold: %d frames = %d ms)", silence_frames_needed, self._vad_silence_ms) + stream: Optional[sd.InputStream] = None + try: + stream = sd.InputStream(samplerate=self._vad_sample_rate, channels=1, dtype='int16', blocksize=frames_per_buffer, device=self._input_device_id, callback=audio_callback) + stream.start() + last_vad_check_time = time.monotonic() + while True: + now = time.monotonic() + try: frame_bytes = audio_queue.get(timeout=0.1) + except queue.Empty: + if now - start_time > 2.0 and not triggered and now - last_vad_check_time > 1.0 : logger.warning("No audio received from VAD input stream for ~%.1f seconds.", now - start_time); last_vad_check_time = now + continue + if len(frame_bytes) != vad_bytes_per_frame: logger.warning("VAD frame unexpected size: %d bytes", len(frame_bytes)); continue + try: is_speech = self._vad.is_speech(frame_bytes, self._vad_sample_rate) + except Exception as vad_err: logger.error("WebRTC VAD error: %s", vad_err); continue + total_frames += 1; last_vad_check_time = now + if is_speech: + if not triggered: logger.debug("VAD triggered."); triggered = True + recorded_frames_bytes.append(frame_bytes); consecutive_silence_frames = 0 + elif triggered: + recorded_frames_bytes.append(frame_bytes); consecutive_silence_frames += 1 + logger.log(5, "Silence frame count: %d/%d", consecutive_silence_frames, silence_frames_needed) + if consecutive_silence_frames >= silence_frames_needed and total_frames >= min_record_frames: + elapsed_ms = (now - start_time) * 1000 + logger.info("Silence detected. Stopping recording. (Frames: %d, Elapsed: %.0f ms)", total_frames, elapsed_ms); break + except sd.PortAudioError as e: logger.error("PortAudio error during VAD recording: %s", e, exc_info=True); return None + except Exception as e: logger.error("Unexpected error during VAD recording: %s", e, exc_info=True); return None + finally: + if stream is not None: + try: + if not stream.closed: stream.stop(); stream.close() + logger.debug("VAD audio stream stopped/closed.") + except Exception as e: logger.error("Error closing VAD stream: %s", e) + if not recorded_frames_bytes: logger.warning("VAD recording finished, but no frames were captured."); return None + try: + audio_data_int16 = np.frombuffer(b"".join(recorded_frames_bytes), dtype=np.int16) + return audio_data_int16.astype(np.float32) / 32767.0 + except Exception as e: logger.error("Failed to convert VAD bytes to numpy: %s", e); return None + + def start_async_recording(self, sample_rate: int): + """Starts recording audio in a background thread.""" + with self._async_recording_active: + if self._async_recording_thread is not None and self._async_recording_thread.is_alive(): + logger.warning("Async recording is already active.") + return False + logger.info("Starting asynchronous PTT recording at %d Hz...", sample_rate) + self._async_sample_rate = sample_rate + self._async_audio_buffer = [] + self._async_recording_stop_event.clear() + self._async_recording_thread = threading.Thread(target=self._async_record_loop, args=(sample_rate,), daemon=True) + self._async_recording_thread.start() + return True + + def _async_record_loop(self, sample_rate: int): + """Background thread for continuous recording.""" + block_size = 1024 + q: queue.Queue[Optional[np.ndarray]] = queue.Queue(maxsize=100) + def record_callback(indata: np.ndarray, frames: int, time_info: Any, status: sd.CallbackFlags): + if status: logger.warning("Async Record Callback Status: %s", str(status)) + try: + if isinstance(indata, np.ndarray): q.put_nowait(indata.copy()) + else: logger.error("Async callback non-numpy: %s", type(indata)) + except queue.Full: logger.warning("Async audio queue full.") + except Exception as e: logger.error("Error in async record callback: %s", e) + + stream: Optional[sd.InputStream] = None + try: + stream = sd.InputStream(samplerate=sample_rate, channels=1, dtype='float32', blocksize=block_size, device=self._input_device_id, callback=record_callback) + stream.start() + logger.debug("Async recording stream started.") + while not self._async_recording_stop_event.is_set(): + try: + chunk = q.get(timeout=0.1) + if chunk is not None: self._async_audio_buffer.append(chunk) + except queue.Empty: continue + logger.debug("Async recording loop received stop signal.") + except sd.PortAudioError as e: logger.error("PortAudioError in async thread: %s", e) + except Exception as e: logger.error("Unexpected error in async thread: %s", e, exc_info=True) + finally: + if stream: + try: + if not stream.closed: stream.stop(); stream.close() + logger.debug("Async recording stream stopped/closed.") + except Exception as e: logger.error("Error closing async stream: %s", e) + logger.debug("Async recording loop finished.") + + def stop_async_recording(self) -> Optional[np.ndarray]: + """Stops the background recording and returns audio.""" + stopped_thread = None; final_audio = None + with self._async_recording_active: + if self._async_recording_thread is None or not self._async_recording_thread.is_alive(): + logger.warning("Async recording not active."); return None + logger.info("Stopping asynchronous PTT recording...") + self._async_recording_stop_event.set() + stopped_thread = self._async_recording_thread + if stopped_thread: + stopped_thread.join(timeout=1.0) + if stopped_thread.is_alive(): logger.warning("Async thread did not stop.") + with self._async_recording_active: + if not self._async_audio_buffer: logger.warning("Async recording captured no audio."); final_audio = None + else: + try: + logger.debug("Concatenating %d chunks.", len(self._async_audio_buffer)) + final_audio = np.concatenate(self._async_audio_buffer, axis=0).flatten() + duration = len(final_audio) / self._async_sample_rate + logger.info("Async recording stopped. Duration: %.2f sec.", duration) + except Exception as e: logger.error("Error processing async buffer: %s", e); final_audio = None + self._async_recording_thread = None; self._async_audio_buffer = []; self._async_recording_stop_event.clear() + return final_audio + + def play_audio( self, audio_data: np.ndarray, sample_rate: int, wait_completion: bool = True ): + """Plays audio data.""" + if audio_data is None or audio_data.size == 0: logger.warning("Attempted to play empty audio."); return + if not isinstance(audio_data, np.ndarray): logger.error("Invalid audio_data type: %s", type(audio_data)); return + if audio_data.dtype != np.float32: + logger.warning("Audio not float32 (%s), converting.", audio_data.dtype); + try: # Simplified conversion attempt + if np.issubdtype(audio_data.dtype, np.integer): audio_data = audio_data.astype(np.float32) / np.iinfo(audio_data.dtype).max + else: audio_data = audio_data.astype(np.float32) + except Exception as e: logger.error("Failed conversion: %s",e); return + + logger.info("Playing audio (%.2f seconds, %d Hz)...", len(audio_data) / sample_rate, sample_rate) + try: + if self._normalize_playback: + max_abs_val = np.max(np.abs(audio_data)) + if max_abs_val == 0: logger.warning("Audio is silent."); return + if max_abs_val > 1.0: logger.warning("Clipping detected. Normalizing."); audio_data = audio_data / max_abs_val + sd.play(audio_data, samplerate=sample_rate, device=self._output_device_id) + if wait_completion: sd.wait(); logger.debug("Audio playback finished.") + except sd.PortAudioError as e: logger.error("PortAudio playback error: %s", e); raise AudioError(...) from e + except Exception as e: logger.error("Unexpected playback error: %s", e); raise AudioError(...) from e + + def stop_playback(self): + """Stops any currently playing audio.""" + logger.info("Stopping audio playback."); sd.stop() + + @staticmethod + def save_wave(filepath: str, audio_data: np.ndarray, sample_rate: int): + """Saves a NumPy audio array to a WAV file.""" + if audio_data is None or audio_data.size == 0: logger.warning("Attempted save empty audio: %s", filepath); return + logger.debug("Saving audio to %s (%d Hz)", filepath, sample_rate) + try: + if audio_data.dtype == np.float32: + audio_data = np.clip(audio_data, -1.0, 1.0); audio_int16 = (audio_data * 32767).astype(np.int16) + elif audio_data.dtype == np.int16: audio_int16 = audio_data + else: logger.error("Unsupported dtype for WAV: %s", audio_data.dtype); raise AudioError(...) + Path(filepath).parent.mkdir(parents=True, exist_ok=True) + with wave.open(filepath, 'wb') as wf: + wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sample_rate) + wf.writeframes(audio_int16.tobytes()) + logger.info("Audio saved successfully: %s", filepath) + except IOError as e: logger.error("Failed write WAV %s: %s", filepath, e); raise AudioError(...) from e + except Exception as e: logger.error("Unexpected WAV save error %s: %s", filepath, e); raise AudioError(...) from e \ No newline at end of file diff --git a/modules/config.py b/modules/config.py deleted file mode 100644 index ea9786d..0000000 --- a/modules/config.py +++ /dev/null @@ -1,20 +0,0 @@ -# modules/config.py -import os -import yaml -from modules.logging import logger - -class ConfigError(Exception): - pass - -_config_cache = None - -def load_config(config_file="settings.yml"): - global _config_cache - if _config_cache is None: - if not os.path.exists(config_file): - logger.error("Configuration file %s not found.", config_file) - raise ConfigError(f"Configuration file {config_file} not found.") - with open(config_file, "r", encoding="utf-8") as f: - _config_cache = yaml.safe_load(f) - logger.info("Configuration loaded from %s", config_file) - return _config_cache diff --git a/modules/config_manager.py b/modules/config_manager.py new file mode 100644 index 0000000..01bfd72 --- /dev/null +++ b/modules/config_manager.py @@ -0,0 +1,162 @@ +# modules/config_manager.py + +import os +from typing import Any, Dict, Optional +import yaml +from pathlib import Path + +# Import logger setup, assuming log_manager.py is adjacent +# Use a try-except block for initial loading robustness before logging is fully configured +try: + from .log_manager import logger +except ImportError: + import logging + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log_manager. Using default logger.") + +class ConfigError(Exception): + """Custom exception for configuration errors.""" + pass + +# --- Private Cache --- +_config_cache: Optional[Dict[str, Any]] = None +_config_path: Optional[Path] = None + +# --- Default Config Path --- +DEFAULT_CONFIG_FILENAME = "settings.yaml" + +def find_config_file(filename: str = DEFAULT_CONFIG_FILENAME) -> Optional[Path]: + """ + Searches for the config file in common locations: + 1. Current working directory. + 2. User's home directory. + 3. Script's directory. + """ + cwd = Path.cwd() + home = Path.home() + script_dir = Path(__file__).parent.parent # Project root (one level up from modules) + + search_paths = [ + cwd / filename, + home / filename, + script_dir / filename, + ] + + for path in search_paths: + if path.is_file(): + logger.debug("Found config file at: %s", path) + return path + + logger.debug("Config file '%s' not found in standard locations.", filename) + return None + + +def load_config(config_path_override: Optional[str] = None) -> Dict[str, Any]: + """ + Loads the application configuration from a YAML file. + + Uses a cached version after the first load unless an override path is given. + Searches for the default file if no path is provided. + + Args: + config_path_override: Explicit path to the configuration file. + If provided, it bypasses search and cache. + + Returns: + A dictionary containing the configuration settings. + + Raises: + ConfigError: If the configuration file cannot be found or loaded. + """ + global _config_cache + global _config_path + + if config_path_override: + # If override path is given, force reload from that path + logger.info("Loading configuration from override path: %s", config_path_override) + path_to_load = Path(config_path_override) + if not path_to_load.is_file(): + logger.error("Specified configuration file not found: %s", path_to_load) + raise ConfigError(f"Specified configuration file not found: {path_to_load}") + _config_path = path_to_load + _config_cache = None # Force reload + elif _config_cache is not None and _config_path is not None: + # Return cached version if no override and already loaded + logger.debug("Returning cached configuration from: %s", _config_path) + return _config_cache + else: + # Find the default config file if not cached and no override + logger.debug("Searching for default configuration file '%s'", DEFAULT_CONFIG_FILENAME) + found_path = find_config_file(DEFAULT_CONFIG_FILENAME) + if not found_path: + logger.error("Default configuration file '%s' not found in standard search locations.", DEFAULT_CONFIG_FILENAME) + raise ConfigError(f"Configuration file '{DEFAULT_CONFIG_FILENAME}' not found.") + path_to_load = found_path + _config_path = path_to_load + logger.info("Loading configuration from: %s", _config_path) + + + # Load the YAML file + try: + with open(path_to_load, "r", encoding="utf-8") as f: + config_data = yaml.safe_load(f) + if not isinstance(config_data, dict): + raise ConfigError(f"Configuration file '{path_to_load}' is not a valid YAML dictionary.") + _config_cache = config_data + logger.debug("Configuration loaded successfully.") + # Add basic validation or schema check here if needed in the future + return _config_cache + except yaml.YAMLError as e: + logger.error("Error parsing YAML file '%s': %s", path_to_load, e, exc_info=True) + raise ConfigError(f"Error parsing configuration file '{path_to_load}': {e}") from e + except IOError as e: + logger.error("Error reading configuration file '%s': %s", path_to_load, e, exc_info=True) + raise ConfigError(f"Could not read configuration file '{path_to_load}': {e}") from e + except Exception as e: + logger.error("An unexpected error occurred while loading config: %s", e, exc_info=True) + raise ConfigError(f"An unexpected error occurred loading config: {e}") from e + + +def get_config() -> Dict[str, Any]: + """ + Returns the loaded configuration dictionary. + + Ensures that the configuration has been loaded, loading it if necessary. + + Returns: + The configuration dictionary. + + Raises: + ConfigError: If the configuration hasn't been loaded and cannot be loaded. + """ + if _config_cache is None: + logger.warning("Configuration accessed before explicit load. Attempting default load.") + return load_config() # Attempt to load with defaults + return _config_cache + +def get_setting(key_path: str, default: Any = None) -> Any: + """ + Retrieves a setting using a dot-separated key path (e.g., "audio.vad.enabled"). + + Args: + key_path: The dot-separated path to the setting. + default: The value to return if the key is not found. Defaults to None. + + Returns: + The setting value or the default value. + """ + config = get_config() + keys = key_path.split('.') + value = config + try: + for key in keys: + if isinstance(value, dict): + value = value[key] + else: + # If we encounter a non-dict while traversing, the path is invalid + logger.warning("Invalid key path '%s' at segment '%s'.", key_path, key) + return default + return value + except (KeyError, TypeError): + logger.debug("Setting '%s' not found, returning default value: %s", key_path, default) + return default \ No newline at end of file diff --git a/modules/hotword_detector.py b/modules/hotword_detector.py deleted file mode 100644 index ba6425a..0000000 --- a/modules/hotword_detector.py +++ /dev/null @@ -1,82 +0,0 @@ -# modules/hotword_detector.py - -from typing import Optional -import os -import numpy as np -import sounddevice as sd -import time -import tempfile -import whisper -from scipy.io.wavfile import write as wav_write -from modules.logging import logger -from modules.config import load_config - -class HotwordDetector: - def __init__(self, config=None): - self.config = config if config is not None else load_config() # Use passed config if available - hotword_config = self.config["hotword"] - audio_config = self.config["audio"] - - self.enabled = hotword_config["enabled"] - self.phrase = hotword_config["phrase"].lower() - self.sensitivity = hotword_config["sensitivity"] - self.timeout = hotword_config["timeout_sec"] - self.retries = hotword_config["retries"] - self.sample_rate = audio_config["hotword_sample_rate"] - - logger.info("Loading Whisper model for hotword detection...") - self.whisper_model = whisper.load_model("tiny.en") - logger.info("Hotword detector ready (listening for '%s')", self.phrase) - - def listen_for_hotword(self) -> bool: - if not self.enabled: - return True # Bypass if disabled - logger.info("Listening for hotword: '%s'...", self.phrase) - for attempt in range(self.retries): - try: - # Use the dedicated check_for_hotword with a 1 second recording - if self.check_for_hotword(timeout=1.0): - logger.info("Hotword detected!") - return True - else: - logger.debug("Attempt %d: Hotword not detected", attempt + 1) - except Exception as e: - logger.error("Hotword detection attempt %d failed: %s", attempt + 1, str(e)) - logger.warning("Hotword not detected") - return False - - def check_for_hotword(self, timeout: float = 1.0) -> bool: - """ - Record for the full timeout duration and transcribe. - Returns True if the hotword is found in the transcription. - """ - device = self.config["audio"]["input_device"] - try: - # Record audio for 'timeout' seconds - num_samples = int(self.sample_rate * timeout) - audio = sd.rec(num_samples, samplerate=self.sample_rate, channels=1, dtype='float32', device=device) - sd.wait() - # Convert audio to int16 as expected by the transcriber - audio_int16 = (audio.flatten() * 32767).astype('int16') - text = self._transcribe_audio(audio_int16) - logger.debug("Hotword check transcription: '%s'", text) - return self.phrase in text.lower() - except Exception as e: - logger.error("Hotword check failed: %s", str(e)) - return False - - def _transcribe_audio(self, audio): - if audio.size == 0: - return "" - # Use a temporary file for Whisper transcription - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - temp_file = tmp.name - wav_write(temp_file, self.sample_rate, audio) - try: - result = self.whisper_model.transcribe(temp_file) - return result.get("text", "").strip() - finally: - try: - os.remove(temp_file) - except Exception as e: - logger.warning("Could not delete temporary file %s: %s", temp_file, str(e)) diff --git a/modules/hotword_manager.py b/modules/hotword_manager.py new file mode 100644 index 0000000..540bc7c --- /dev/null +++ b/modules/hotword_manager.py @@ -0,0 +1,277 @@ +# modules/hotword_manager.py + +import time +import threading +import queue +from typing import Optional, List, Dict, Any + +import numpy as np +import sounddevice as sd +import openwakeword + +# Use logger, config manager, and performance monitor +try: + from .log_manager import logger + from .config_manager import get_setting + from .performance_monitor import PerformanceMonitor +except ImportError: + import logging + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log/config/perf managers. Using defaults.") + # Mock get_setting + def get_setting(key_path: str, default: Optional[Any] = None) -> Any: + defaults = { + "hotword.enabled": False, + "hotword.models": ["hey_jarvis"], + "hotword.inference_framework": "onnx", + "hotword.threshold": 0.7, + "hotword.trigger_level": 1, # OWW internal, usually 1 + "audio.input_device": None, + # OWW expects 16kHz, chunk size influences latency vs efficiency + "hotword.chunk_size_ms": 1280 # Default OWW recommendation is often ~1280ms (80*16 samples) + } + return defaults.get(key_path, default) + # Mock PerformanceMonitor + class MockPerformanceMonitor: + def record_event(self, name, count=1.0): pass + # performance_monitor = MockPerformanceMonitor() # Example instantiation + + +# --- Constants --- +EXPECTED_SAMPLE_RATE = 16000 # openwakeword expects 16kHz + +class HotwordError(Exception): + """Custom exception for Hotword detection errors.""" + pass + +class HotwordManager: + """ + Manages hotword detection using OpenWakeWord in a background thread. + """ + + def __init__(self, performance_monitor: Optional[PerformanceMonitor] = None): + logger.info("Initializing HotwordManager...") + self._perf_monitor = performance_monitor + self._enabled: bool = get_setting("hotword.enabled", False) + self._input_device_id: Optional[int] = get_setting("audio.input_device", None) + + self._oww_model: Optional[openwakeword.OpenWakeWord] = None + self._listener_thread: Optional[threading.Thread] = None + self._audio_stream: Optional[sd.InputStream] = None + self._running = threading.Event() # Event to signal thread to stop + self._detection_lock = threading.Lock() + self._last_detection: Optional[str] = None # Stores the name of the last detected hotword + self._chunk_queue: queue.Queue = queue.Queue(maxsize=10) # Queue for audio chunks + + if not self._enabled: + logger.info("Hotword detection is disabled in configuration.") + return + + # --- Load Configuration --- + self._model_names: List[str] = get_setting("hotword.models", ["hey_jarvis"]) + # self._custom_model_paths: List[str] = get_setting("hotword.custom_model_paths", []) # If supporting custom + self._inference_framework: str = get_setting("hotword.inference_framework", "onnx") + self._threshold: float = get_setting("hotword.threshold", 0.7) + # Note: trigger_level is often handled internally by oww based on chunk size, but check docs if issues arise + # self._trigger_level: int = get_setting("hotword.trigger_level", 1) + self._chunk_size_ms: int = get_setting("hotword.chunk_size_ms", 1280) + + # Calculate chunk size in samples + self._chunk_samples = int(EXPECTED_SAMPLE_RATE * self._chunk_size_ms / 1000) + + if not self._model_names: + logger.warning("Hotword detection enabled, but no models specified in config. Disabling.") + self._enabled = False + return + + self._load_models() + + @property + def is_enabled(self) -> bool: + return self._enabled + + @property + def is_running(self) -> bool: + return self._listener_thread is not None and self._listener_thread.is_alive() + + def _load_models(self): + """Loads the OpenWakeWord models.""" + if not self._enabled: return + + logger.info("Loading OpenWakeWord models: %s", self._model_names) + # Add performance timing if desired + try: + # Combine pre-defined and custom paths if feature is added + # model_paths = self._model_names + self._custom_model_paths + self._oww_model = openwakeword.OpenWakeWord( + wakeword_models=self._model_names, # Pass list of names/paths + inference_framework=self._inference_framework, + # Other parameters if needed (e.g., custom thresholds per model) + ) + logger.info("OpenWakeWord models loaded successfully.") + # Store activation counts per model if needed for trigger level logic + # self._activation_counts = {name: 0 for name in self._oww_model.models} + + except Exception as e: + logger.error("Failed to load OpenWakeWord models: %s", e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors", 1) + self._enabled = False # Disable if loading fails + raise HotwordError(f"Failed to load OpenWakeWord models: {e}") from e + + def start(self): + """Starts the background hotword listening thread.""" + if not self._enabled: + logger.debug("Cannot start HotwordManager: disabled.") + return + if self.is_running: + logger.warning("HotwordManager listener thread already running.") + return + if self._oww_model is None: + logger.error("Cannot start HotwordManager: models not loaded.") + return + + logger.info("Starting hotword listener thread...") + self._running.set() # Signal that the thread should run + self._last_detection = None # Reset detection state + self._listener_thread = threading.Thread(target=self._listener_loop, daemon=True) + self._listener_thread.start() + + def stop(self): + """Stops the background hotword listening thread.""" + if not self.is_running: + logger.debug("HotwordManager listener thread is not running.") + return + + logger.info("Stopping hotword listener thread...") + self._running.clear() # Signal thread to stop + + # Put a sentinel value in the queue to unblock the get() if necessary + try: + self._chunk_queue.put_nowait(None) + except queue.Full: + pass # Queue might be full, thread should check self._running soon anyway + + if self._listener_thread: + self._listener_thread.join(timeout=2.0) # Wait for thread to finish + if self._listener_thread.is_alive(): + logger.warning("Hotword listener thread did not stop gracefully.") + self._listener_thread = None + + # Clear the queue after stopping + while not self._chunk_queue.empty(): + try: + self._chunk_queue.get_nowait() + except queue.Empty: + break + + logger.info("Hotword listener stopped.") + + + def _listener_loop(self): + """The main loop running in the background thread.""" + try: + logger.debug("Hotword listener thread started.") + # Setup audio stream + self._audio_stream = sd.InputStream( + samplerate=EXPECTED_SAMPLE_RATE, + channels=1, + dtype='int16', # OWW expects int16 + blocksize=self._chunk_samples, # Use calculated chunk size + device=self._input_device_id, + callback=self._audio_callback + ) + self._audio_stream.start() + logger.info("Hotword audio stream started. Listening...") + + while self._running.is_set(): + try: + # Get chunk from the queue filled by the callback + chunk = self._chunk_queue.get(timeout=0.5) # Wait briefly + if chunk is None: # Sentinel value + break + + # Feed chunk to OpenWakeWord + if self._oww_model: + prediction = self._oww_model.predict(chunk) + + # Check results (prediction is a dict: {'model_name': score}) + for model_name, score in prediction.items(): + if score >= self._threshold: + logger.info( + "Hotword detected: '%s' (Score: %.2f)", + model_name, score + ) + with self._detection_lock: + self._last_detection = model_name + if self._perf_monitor: + self._perf_monitor.record_event("hotword_detections", 1) + # Optional: Add a brief pause/cooldown after detection? + # time.sleep(1.0) + # Reset OWW internal state if needed after detection? Check docs. + # self._oww_model.reset() # Example if needed + + except queue.Empty: + # Timeout waiting for audio chunk, just continue loop if running + continue + except Exception as e: + logger.error("Error in hotword listener loop: %s", e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors", 1) + time.sleep(1) # Avoid spamming logs on continuous errors + + except sd.PortAudioError as e: + logger.error("PortAudioError setting up hotword stream: %s", e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors", 1) + self._enabled = False # Disable if stream fails + except Exception as e: + logger.error("Unexpected error setting up hotword stream: %s", e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors", 1) + self._enabled = False + finally: + # Cleanup stream + if self._audio_stream: + try: + if not self._audio_stream.closed: + self._audio_stream.stop() + self._audio_stream.close() + logger.debug("Hotword audio stream closed.") + except Exception as e: + logger.error("Error closing hotword audio stream: %s", e) + self._audio_stream = None + logger.debug("Hotword listener thread finished.") + + + def _audio_callback(self, indata: np.ndarray, frames: int, time_info: Any, status: sd.CallbackFlags): + """Callback function for the sounddevice InputStream.""" + if status: + logger.warning("Hotword InputStream status: %s", status) + if self._perf_monitor: self._perf_monitor.record_event("audio_input_errors", 1) + return + + if not self._running.is_set(): + return # Don't process if stopping + + try: + # indata should already be int16 based on stream setup + self._chunk_queue.put_nowait(indata) + except queue.Full: + logger.warning("Hotword audio queue is full. Dropping chunk.") + if self._perf_monitor: self._perf_monitor.record_event("audio_drops", 1) + + + def get_detected_keyword(self) -> Optional[str]: + """ + Checks if a hotword has been detected since the last call. + + This method is thread-safe and resets the detection flag. + + Returns: + The name of the detected keyword (str) or None if no detection occurred. + """ + if not self._enabled: + return None + + detected = None + with self._detection_lock: + detected = self._last_detection + self._last_detection = None # Reset after checking + return detected \ No newline at end of file diff --git a/modules/llm_manager.py b/modules/llm_manager.py new file mode 100644 index 0000000..83ba8fb --- /dev/null +++ b/modules/llm_manager.py @@ -0,0 +1,210 @@ +# modules/llm_manager.py + +import time +import requests +import json +from typing import Optional, List, Dict, Any + +# Use logger, config manager, and performance monitor +try: + from .log_manager import logger + from .config_manager import get_setting + from .performance_monitor import PerformanceMonitor +except ImportError: + import logging + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log/config/perf managers. Using defaults.") + # Mock get_setting + def get_setting(key_path: str, default: Optional[Any] = None) -> Any: + defaults = { + "llm.base_url": "http://127.0.0.1:1234/v1", + "llm.request_timeout_sec": 20.0, + "llm.max_retries": 3, + "llm.chat.endpoint": "/chat/completions", + "llm.chat.model": "local-model", # Placeholder + "llm.chat.system_prompt": "You are a helpful assistant.", + "llm.chat.max_tokens": 300, + "llm.chat.temperature": 0.7, + "llm.chat.top_p": 0.9, + "llm.chat.repetition_penalty": 1.1, + } + nested_keys = key_path.split('.') + val = defaults + try: + for k in nested_keys: val = val[k] + return val + except KeyError: + return default + # Mock PerformanceMonitor + class MockPerformanceMonitor: + def start_timer(self, name): pass + def stop_timer(self, name, record_count=True): pass + def record_event(self, name, count=1.0): pass + # performance_monitor = MockPerformanceMonitor() # Example instantiation + +# --- Constants --- +DEFAULT_ERROR_RESPONSE = "I'm having trouble connecting to my brain right now. Please try again in a moment." +TIMEOUT_RESPONSE = "I need a little more time to think about that. Could you ask again?" + +class LLMError(Exception): + """Custom exception for LLM interaction errors.""" + pass + +class LLMManager: + """Handles interactions with the Language Model API (e.g., LM Studio).""" + + def __init__(self, performance_monitor: Optional[PerformanceMonitor] = None): + logger.info("Initializing LLMManager...") + self._perf_monitor = performance_monitor + + # --- Load LLM Configuration --- + self._base_url: str = get_setting("llm.base_url", "http://127.0.0.1:1234/v1").rstrip('/') + self._timeout: float = get_setting("llm.request_timeout_sec", 20.0) + self._max_retries: int = get_setting("llm.max_retries", 3) + + # --- Chat Specific Configuration --- + self._chat_endpoint: str = get_setting("llm.chat.endpoint", "/chat/completions").lstrip('/') + self._chat_model: str = get_setting("llm.chat.model", "local-model") + self._system_prompt: str = get_setting("llm.chat.system_prompt", "You are a helpful assistant.") + self._chat_max_tokens: int = get_setting("llm.chat.max_tokens", 300) + self._chat_temperature: float = get_setting("llm.chat.temperature", 0.7) + self._chat_top_p: float = get_setting("llm.chat.top_p", 0.9) + self._chat_repetition_penalty: float = get_setting("llm.chat.repetition_penalty", 1.1) + + # --- Request Setup --- + self._session = requests.Session() + self._headers = {"Content-Type": "application/json"} + self._chat_url = f"{self._base_url}/{self._chat_endpoint}" + + logger.info("LLMManager configured for URL: %s", self._chat_url) + logger.info("Chat Model: %s", self._chat_model) + + + def generate_chat_response(self, user_input: str, chat_history: Optional[List[Dict[str, str]]] = None) -> str: + """ + Sends input to the LLM chat endpoint and returns the generated response. + + Args: + user_input: The latest input from the user. + chat_history: (Optional) A list of previous messages in the conversation, + following the OpenAI format: [{"role": "user/assistant", "content": "..."}, ...] + + Returns: + The assistant's generated response (str), or a default error message if generation fails. + """ + if not user_input: + logger.warning("Received empty user input for chat.") + return "I didn't catch that. Could you please repeat?" + + # Construct messages payload + messages = [{"role": "system", "content": self._system_prompt}] + if chat_history: + messages.extend(chat_history) + messages.append({"role": "user", "content": user_input}) + + # Construct request payload according to LM Studio / OpenAI API format + payload = { + "model": self._chat_model, + "messages": messages, + "max_tokens": self._chat_max_tokens, + "temperature": self._chat_temperature, + "top_p": self._chat_top_p, + "repeat_penalty": self._chat_repetition_penalty, # Note: LM Studio uses 'repeat_penalty' + "stream": False # We want the complete response here + # Add other parameters if supported/needed (e.g., presence_penalty, frequency_penalty) + } + + logger.info("Sending chat request to LLM...") + logger.debug("Chat Request Payload: %s", json.dumps(payload, indent=2)) # Log full payload at debug + + if self._perf_monitor: + self._perf_monitor.start_timer("llm_response_time") + self._perf_monitor.record_event("llm_requests") + + response_text = DEFAULT_ERROR_RESPONSE # Default in case of failure + + for attempt in range(self._max_retries): + try: + response = self._session.post( + self._chat_url, + headers=self._headers, + json=payload, + timeout=self._timeout + ) + response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx) + + data = response.json() + logger.debug("LLM Raw Response: %s", json.dumps(data, indent=2)) + + # Extract response content (structure may vary slightly by LLM server) + # Assuming OpenAI compatible structure: + if data.get("choices") and isinstance(data["choices"], list) and len(data["choices"]) > 0: + message = data["choices"][0].get("message") + if message and isinstance(message, dict): + response_text = message.get("content", "").strip() + if response_text: + logger.info("LLM response received successfully.") + # Estimate token count (very rough) + token_est = len(response_text.split()) * 1.33 + logger.info("LLM Response (~%d tokens): \"%s\"", + int(token_est), + (response_text[:70] + '...') if len(response_text) > 70 else response_text + ) + if self._perf_monitor: self._perf_monitor.record_event("llm_output_tokens", token_est) + break # Success, exit retry loop + else: + logger.warning("LLM response 'content' was empty.") + else: + logger.warning("LLM response missing 'message' structure in 'choices'.") + else: + logger.warning("LLM response missing 'choices' structure.") + + # If we got here, extraction failed or response was empty + response_text = DEFAULT_ERROR_RESPONSE # Reset to error message + + except requests.exceptions.Timeout: + logger.warning("LLM request timed out (Attempt %d/%d)", attempt + 1, self._max_retries) + if attempt == self._max_retries - 1: + response_text = TIMEOUT_RESPONSE + if self._perf_monitor: self._perf_monitor.record_event("errors") + break + # Exponential backoff delay + delay = 1 * (2 ** attempt) + logger.info("Retrying LLM request in %d seconds...", delay) + time.sleep(delay) + + except requests.exceptions.RequestException as e: + logger.error("LLM request failed (Attempt %d/%d): %s", attempt + 1, self._max_retries, e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors") + if attempt == self._max_retries - 1: + response_text = DEFAULT_ERROR_RESPONSE + break # Max retries reached + delay = 1 * (2 ** attempt) + logger.info("Retrying LLM request in %d seconds...", delay) + time.sleep(delay) + + except json.JSONDecodeError as e: + logger.error("Failed to decode LLM JSON response: %s", e) + logger.debug("LLM Raw Response Text: %s", response.text) # Log raw text if JSON fails + if self._perf_monitor: self._perf_monitor.record_event("errors") + response_text = DEFAULT_ERROR_RESPONSE + break # Don't retry on decode error + + except Exception as e: + logger.critical("Unexpected error during LLM chat request: %s", e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors") + response_text = DEFAULT_ERROR_RESPONSE + # Depending on the error, might want to break or retry + break # Break on unexpected errors for now + + # End of retry loop + + if self._perf_monitor: + self._perf_monitor.stop_timer("llm_response_time") # Always stop timer + + return response_text + + def close_session(self): + """Closes the underlying requests session.""" + logger.debug("Closing LLMManager requests session.") + self._session.close() \ No newline at end of file diff --git a/modules/lm_client.py b/modules/lm_client.py deleted file mode 100644 index 0d7487b..0000000 --- a/modules/lm_client.py +++ /dev/null @@ -1,218 +0,0 @@ -# modules/lm_client.py -import os -import time -import json -import wave -import requests -import re -from typing import Optional -from modules.logging import logger -from modules.audio import segment_text, combine_audio_files -from modules.snac_decoder import tokens_decoder_sync -from modules.config import load_config - -def clean_text_for_tts(text: str) -> str: - """ - Clean the text to be sent to the TTS engine by: - • Removing newline characters and excessive whitespace. - • Removing markdown symbols (e.g., asterisks). - • Removing non-ASCII characters (e.g., emojis). - """ - # Remove newline characters - text = text.replace('\n', ' ') - # Remove markdown formatting - text = re.sub(r'\*+', '', text) - # Remove non-ASCII characters (e.g., emojis) - text = re.sub(r'[^\x00-\x7F]+', '', text) - # Normalize whitespace - text = re.sub(r'\s+', ' ', text) - return text.strip() - -class LMStudioClient: - def __init__(self, config): - self.config = config - lm_config = config["lm"] - - self.api_url = lm_config["api_url"] - self.chat_endpoint = lm_config["chat"]["endpoint"] - self.tts_endpoint = lm_config["tts"]["endpoint"] - - # Chat parameters - chat_config = lm_config["chat"] - self.chat_model = chat_config["model"] - self.system_prompt = chat_config["system_prompt"] - self.chat_max_tokens = chat_config["max_tokens"] - self.chat_temperature = chat_config["temperature"] - self.chat_top_p = chat_config["top_p"] - self.chat_repetition_penalty = chat_config["repetition_penalty"] - self.max_response_time = chat_config["max_response_time"] - - # TTS parameters - tts_config = lm_config["tts"] - self.tts_model = tts_config["model"] - self.default_voice = tts_config["default_voice"] - self.tts_max_tokens = tts_config["max_tokens"] - self.tts_temperature = tts_config["temperature"] - self.tts_top_p = tts_config["top_p"] - self.tts_repetition_penalty = tts_config["repetition_penalty"] - self.speed = tts_config["speed"] - self.max_segment_duration = tts_config["max_segment_duration"] - - self.headers = {"Content-Type": "application/json"} - self.retries = config["speech"]["max_retries"] - self.tts_sample_rate = config["tts"]["sample_rate"] - - # Use a session for connection pooling - self.session = requests.Session() - - def chat(self, user_input: str) -> str: - messages = [ - {"role": "system", "content": self.system_prompt}, - {"role": "user", "content": user_input} - ] - payload = { - "model": self.chat_model, - "messages": messages, - "max_tokens": self.chat_max_tokens, - "temperature": self.chat_temperature, - "top_p": self.chat_top_p, - "repeat_penalty": self.chat_repetition_penalty, - "stream": False - } - url = self.api_url + self.chat_endpoint - logger.debug("Chat request payload: %s", payload) - for attempt in range(self.retries): - try: - start_time = time.time() - response = self.session.post( - url, - headers=self.headers, - json=payload, - timeout=self.max_response_time - ) - elapsed = time.time() - start_time - logger.info("Chat response received in %.2f seconds", elapsed) - logger.debug("LM Studio full response: %s", response.text) - if response.status_code != 200: - logger.error("Chat API error: %s %s", response.status_code, response.text) - if attempt < self.retries - 1: - delay = 2 ** attempt - logger.warning("Chat API error, retrying in %d seconds...", delay) - time.sleep(delay) - continue - raise RuntimeError(f"Chat API error: {response.status_code} {response.text}") - data = response.json() - generated_text = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() - token_count = len(generated_text.split()) * 1.33 - logger.info("Generated %d tokens: %s", int(token_count), generated_text[:50]) - return generated_text - except requests.exceptions.Timeout: - delay = 2 ** attempt - logger.warning("Chat API timeout (attempt %d), retrying in %d seconds", attempt + 1, delay) - time.sleep(delay) - if attempt == self.retries - 1: - return "I need more time to think about that. Could you ask again?" - except Exception as e: - delay = 2 ** attempt - logger.error("Chat API failed (attempt %d): %s", attempt + 1, str(e)) - time.sleep(delay) - if attempt == self.retries - 1: - return "I'm having trouble responding right now. Please try again later." - - def synthesize_speech(self, text: str, voice: Optional[str] = None, output_file: Optional[str] = None) -> str: - voice = voice if voice else self.default_voice - if not voice.isalpha() or len(voice) > 20: - logger.warning("Invalid voice name '%s', using default", voice) - voice = self.default_voice - - # Clean the text for TTS - cleaned_text = clean_text_for_tts(text) - prompt = f"<|audio|>{voice}: {cleaned_text}<|eot_id|>" - payload = { - "model": self.tts_model, - "prompt": prompt, - "max_tokens": self.tts_max_tokens, - "temperature": self.tts_temperature, - "top_p": self.tts_top_p, - "repeat_penalty": self.tts_repetition_penalty, - "speed": self.speed, - "stream": True - } - url = self.api_url + self.tts_endpoint - logger.debug("TTS request payload: %s", payload) - for attempt in range(self.retries): - try: - response = self.session.post( - url, - headers=self.headers, - json=payload, - stream=True, - timeout=self.max_segment_duration + 5 - ) - if response.status_code != 200: - logger.error("TTS API error: %s %s", response.status_code, response.text) - if attempt < self.retries - 1: - delay = 2 ** attempt - logger.warning("TTS API error, retrying in %d seconds...", delay) - time.sleep(delay) - continue - raise RuntimeError(f"TTS API error: {response.status_code} {response.text}") - - def token_generator(): - for line in response.iter_lines(): - if line: - decoded_line = line.decode("utf-8") - if decoded_line.startswith("data: "): - data_str = decoded_line[6:] - if data_str.strip() == "[DONE]": - break - try: - data = json.loads(data_str) - token_text = data.get("choices", [{}])[0].get("text", "") - yield token_text - except json.JSONDecodeError as e: - logger.error("JSON decode error: %s", e) - - audio_bytes = tokens_decoder_sync(token_generator()) - if not output_file: - timestamp = int(time.time()) - output_file = f"outputs/{voice}_{timestamp}.wav" - os.makedirs("outputs", exist_ok=True) - with wave.open(output_file, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(2) - wf.setframerate(self.tts_sample_rate) - wf.writeframes(audio_bytes) - logger.info("Audio saved to %s", output_file) - return output_file - except Exception as e: - delay = 2 ** attempt - logger.error("TTS synthesis failed (attempt %d): %s", attempt + 1, str(e)) - time.sleep(delay) - if attempt == self.retries - 1: - raise - - def synthesize_long_text(self, text: str, voice: Optional[str] = None) -> str: - voice = voice if voice else self.default_voice - segments = segment_text(text, max_words=self.config["segmentation"]["max_words"]) - logger.info("Text segmented into %d parts", len(segments)) - file_list = [] - for i, seg in enumerate(segments): - seg_filename = f"outputs/{voice}_{int(time.time())}_{i}.wav" - try: - self.synthesize_speech(seg, voice=voice, output_file=seg_filename) - file_list.append(seg_filename) - time.sleep(0.2) # Small delay between segments - except Exception as e: - logger.error("Failed to synthesize segment %d: %s", i, str(e)) - if file_list: - break - raise - combined_filename = f"outputs/{voice}_{int(time.time())}_combined.wav" - combine_audio_files(file_list, combined_filename) - for f in file_list: - try: - os.remove(f) - except Exception as e: - logger.warning("Could not remove temporary file %s: %s", f, str(e)) - return combined_filename diff --git a/modules/log_manager.py b/modules/log_manager.py new file mode 100644 index 0000000..fa81dce --- /dev/null +++ b/modules/log_manager.py @@ -0,0 +1,124 @@ +# modules/log_manager.py + +import os +import sys +import logging +from logging.handlers import RotatingFileHandler +from datetime import datetime +from typing import Optional, Any + +# Make sure rich is installed: pip install rich +# Import only the module name, check for it later +_rich_logging_available = False +try: + from rich.logging import RichHandler + _rich_logging_available = True +except ImportError: + RichHandler = None # Keep None available for type hinting if needed later + +# --- Constants --- +LOG_DIR = "log"; MAX_LOG_SIZE_BYTES = 5*1024*1024; LOG_BACKUP_COUNT = 3 +FILE_LOG_FORMAT = "%(asctime)s - %(name)s - [%(levelname)s] - %(filename)s:%(lineno)d - %(message)s" +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# --- Global Logger --- +logger = logging.getLogger("morpheus") + +# --- Configuration Function --- +def setup_logging( + log_level: str = "INFO", + log_dir: str = LOG_DIR, + max_log_size: int = MAX_LOG_SIZE_BYTES, + backup_count: int = LOG_BACKUP_COUNT, + app_name: str = "morpheus" +) -> logging.Logger: + """Configures logging with Rich console (if available) and file logging.""" + global logger + logger = logging.getLogger(app_name) + level = getattr(logging, log_level.upper(), logging.INFO) + logger.setLevel(level) + + # Prevent adding multiple handlers + if logger.hasHandlers(): logger.handlers.clear() + + # --- Console Handler --- + console_handler: Optional[logging.Handler] = None + if _rich_logging_available and RichHandler is not None: # Check BOTH flag and type alias + try: + # --- Try creating Rich Handler ONLY if import succeeded --- + console_handler = RichHandler( + rich_tracebacks=True, + show_path=False, + markup=True, + show_level=True, + ) + console_handler.setLevel(level) + logger.addHandler(console_handler) + # Log first message using potential Rich handler + logger.info("Rich console handler enabled. Console Level: %s.", log_level.upper()) + except Exception as rich_err: + print(f"Warning: Failed to initialize RichHandler: {rich_err}. Falling back.", file=sys.stderr) + # Ensure handler is None if init failed + console_handler = None + # Remove potentially partially added handler if logger has it + if logger.hasHandlers(): + logger.handlers[:] = [h for h in logger.handlers if not isinstance(h, RichHandler)] + + # Fallback or if RichHandler failed/unavailable + if console_handler is None: + # Check if a basic handler is already added (e.g., by basicConfig guard) + if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + basic_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S') + console_handler.setFormatter(basic_formatter) + logger.addHandler(console_handler) + logger.info("Using basic console logging. Console Level: %s.", log_level.upper()) + else: + # A basic handler (likely from basicConfig) already exists, use it + logger.info("Basic console handler already present. Console Level: %s.", log_level.upper()) + + + # --- File Handlers --- + try: + os.makedirs(log_dir, exist_ok=True) + # General File Handler + log_filename = os.path.join(log_dir, f"{app_name}_{datetime.now().strftime('%Y%m%d')}.log") + file_handler = RotatingFileHandler(log_filename, maxBytes=max_log_size, backupCount=backup_count, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter(FILE_LOG_FORMAT, datefmt=DATE_FORMAT) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + # Error File Handler + error_log_filename = os.path.join(log_dir, f"{app_name}_errors_{datetime.now().strftime('%Y%m%d')}.log") + error_handler = RotatingFileHandler(error_log_filename, maxBytes=max_log_size, backupCount=backup_count, encoding="utf-8") + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(file_formatter) + logger.addHandler(error_handler) + logger.debug("File logging initialized to directory: '%s'", log_dir) + except Exception as e: + logger.error("Failed to set up file logging: %s", e, exc_info=True) + logger.warning("File logging is disabled.") + + # --- Capture warnings --- + logging.captureWarnings(True) + warnings_logger = logging.getLogger("py.warnings") + warnings_logger.propagate = False + # Clear any previous handlers from warnings logger + if warnings_logger.hasHandlers(): warnings_logger.handlers.clear() + # Add *current* handlers from main logger to warnings logger + for handler in logger.handlers: + # Avoid adding file handlers multiple times if setup is called again + if not any(isinstance(h, type(handler)) and getattr(h, 'baseFilename', None) == getattr(handler, 'baseFilename', ' ') for h in warnings_logger.handlers): + warnings_logger.addHandler(handler) + + logger.info("Logging setup complete.") + return logger + +# --- Initial Setup Guard REMOVED --- +# Removing this guard. If logger is accessed early, it might raise NoHandlerError, +# which is acceptable as setup_logging *must* be called by main() after config load. +# This prevents the fallback basicConfig from interfering with RichHandler setup. +# if not logger.hasHandlers(): +# logging.basicConfig(level=logging.WARNING, format='%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S') +# logger.warning("Logger accessed before explicit setup. Using basicConfig as fallback.") \ No newline at end of file diff --git a/modules/logging.py b/modules/logging.py deleted file mode 100644 index 2d41510..0000000 --- a/modules/logging.py +++ /dev/null @@ -1,35 +0,0 @@ -# modules/logging.py -import os -import sys -import logging -from logging.handlers import RotatingFileHandler -from datetime import datetime - -LOG_DIR = "log" -os.makedirs(LOG_DIR, exist_ok=True) -log_filename = f"{LOG_DIR}/assistant_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" -error_log_filename = f"{LOG_DIR}/errors_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" - -logger = logging.getLogger("AssistantLogger") -logger.setLevel(logging.DEBUG) - -# Console handler -ch = logging.StreamHandler(sys.stdout) -ch.setLevel(logging.DEBUG) - -# Rotating file handler for all logs (max 5MB per file, up to 3 backups) -fh = RotatingFileHandler(log_filename, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8") -fh.setLevel(logging.DEBUG) - -# Rotating file handler for errors only -eh = RotatingFileHandler(error_log_filename, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8") -eh.setLevel(logging.ERROR) - -formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S') -ch.setFormatter(formatter) -fh.setFormatter(formatter) -eh.setFormatter(formatter) - -logger.addHandler(ch) -logger.addHandler(fh) -logger.addHandler(eh) diff --git a/modules/performance.py b/modules/performance.py deleted file mode 100644 index 3d0dbbe..0000000 --- a/modules/performance.py +++ /dev/null @@ -1,34 +0,0 @@ -# modules/performance.py -import time -from modules.logging import logger - -class PerformanceMonitor: - def __init__(self, report_interval=60): - self.start_time = time.time() - self.token_count = 0 - self.audio_chunks = 0 - self.api_calls = 0 - self.errors = 0 - self.last_report_time = time.time() - self.report_interval = report_interval - - def add_tokens(self, count=1): - self.token_count += count - - def add_audio_chunk(self): - self.audio_chunks += 1 - - def add_api_call(self): - self.api_calls += 1 - - def add_error(self): - self.errors += 1 - - def report(self, force=False): - now = time.time() - if force or (now - self.last_report_time) >= self.report_interval: - elapsed = now - self.start_time - tokens_per_sec = self.token_count / elapsed if elapsed > 0 else 0 - logger.info("Performance: %.1f tokens/sec | %d API calls | %d errors | %d audio chunks", - tokens_per_sec, self.api_calls, self.errors, self.audio_chunks) - self.last_report_time = now diff --git a/modules/performance_monitor.py b/modules/performance_monitor.py new file mode 100644 index 0000000..1b5b895 --- /dev/null +++ b/modules/performance_monitor.py @@ -0,0 +1,108 @@ +# modules/performance_monitor.py + +import time +from collections import defaultdict +from typing import Dict, Any + +# Use logger, assuming log_manager is available +try: + from .log_manager import logger +except ImportError: + import logging + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log_manager. Using default logger for PerformanceMonitor.") + +class PerformanceMonitor: + """ + Tracks various performance metrics of the virtual assistant. + """ + def __init__(self): + self._start_time: float = time.monotonic() + self._metrics: Dict[str, Any] = defaultdict(float) + self._timers: Dict[str, float] = {} # For tracking durations + logger.info("Performance monitor initialized.") + + def record_event(self, event_name: str, count: float = 1.0): + """ + Increments a counter for a specific event. + + Examples: 'llm_requests', 'tts_requests', 'hotword_detections', 'errors' + """ + self._metrics[event_name] += count + logger.debug("Event recorded: %s (+%.1f)", event_name, count) + + def start_timer(self, timer_name: str): + """ + Starts a timer for a specific operation. + + Examples: 'transcription_time', 'llm_response_time', 'tts_synthesis_time' + """ + self._timers[timer_name] = time.monotonic() + logger.debug("Timer started: %s", timer_name) + + def stop_timer(self, timer_name: str, record_count: bool = True): + """ + Stops a timer and records the duration in milliseconds. + + Args: + timer_name: The name of the timer to stop (must match start_timer). + record_count: If True, also increments a counter named f"{timer_name}_count". + """ + if timer_name in self._timers: + end_time = time.monotonic() + duration_ms = (end_time - self._timers[timer_name]) * 1000 + # Store total duration and count to calculate average later + total_duration_key = f"{timer_name}_total_ms" + count_key = f"{timer_name}_count" + + self._metrics[total_duration_key] += duration_ms + if record_count: + self._metrics[count_key] += 1 + + logger.debug( + "Timer stopped: %s, Duration: %.2f ms", timer_name, duration_ms + ) + del self._timers[timer_name] # Remove timer once stopped + else: + logger.warning("Attempted to stop timer '%s' that was not started.", timer_name) + + def set_value(self, metric_name: str, value: Any): + """Sets a specific metric to a given value (e.g., current model name).""" + self._metrics[metric_name] = value + logger.debug("Metric set: %s = %s", metric_name, value) + + def get_metrics(self) -> Dict[str, Any]: + """Returns a copy of the current metrics.""" + # Add overall uptime + metrics_copy = self._metrics.copy() + metrics_copy["uptime_seconds"] = time.monotonic() - self._start_time + return metrics_copy + + def get_summary(self) -> str: + """Generates a formatted string summary of key performance indicators.""" + metrics = self.get_metrics() + uptime_sec = metrics.get("uptime_seconds", 0) + llm_reqs = metrics.get("llm_requests", 0) + tts_reqs = metrics.get("tts_requests", 0) + stt_reqs = metrics.get("stt_requests", 0) + errors = metrics.get("errors", 0) + + summary = f"Uptime: {time.strftime('%H:%M:%S', time.gmtime(uptime_sec))}" + summary += f" | LLM: {int(llm_reqs)}" + summary += f" | TTS: {int(tts_reqs)}" + summary += f" | STT: {int(stt_reqs)}" + summary += f" | Errors: {int(errors)}" + + # Add average times if available + for timer_base in ["transcription_time", "llm_response_time", "tts_synthesis_time"]: + total_ms = metrics.get(f"{timer_base}_total_ms", 0) + count = metrics.get(f"{timer_base}_count", 0) + if count > 0: + avg_ms = total_ms / count + summary += f" | Avg {timer_base.split('_')[0].upper()}: {avg_ms:.0f}ms" + + return summary + + def log_summary(self): + """Logs the performance summary using the configured logger.""" + logger.info("Performance Summary: %s", self.get_summary()) \ No newline at end of file diff --git a/modules/snac_decoder.py b/modules/snac_decoder.py deleted file mode 100644 index 70efd98..0000000 --- a/modules/snac_decoder.py +++ /dev/null @@ -1,92 +0,0 @@ -# modules/snac_decoder.py - -import time -import torch -import numpy as np -from modules.logging import logger -from snac import SNAC # Ensure that the snac module is installed - -# Load SNAC model -snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval() -snac_device = "cuda" if torch.cuda.is_available() else "cpu" -logger.info("Using SNAC on device: %s", snac_device) -snac_model = snac_model.to(snac_device) -cuda_stream = torch.cuda.Stream() if snac_device == "cuda" else None - -def convert_to_audio(multiframe, count): - if len(multiframe) < 7: - return None - num_frames = len(multiframe) // 7 - frame = multiframe[:num_frames * 7] - codes_0 = torch.zeros(num_frames, dtype=torch.int32, device=snac_device) - codes_1 = torch.zeros(num_frames * 2, dtype=torch.int32, device=snac_device) - codes_2 = torch.zeros(num_frames * 4, dtype=torch.int32, device=snac_device) - frame_tensor = torch.tensor(frame, dtype=torch.int32, device=snac_device) - for j in range(num_frames): - idx = j * 7 - codes_0[j] = frame_tensor[idx] - codes_1[j * 2] = frame_tensor[idx + 1] - codes_1[j * 2 + 1] = frame_tensor[idx + 4] - codes_2[j * 4] = frame_tensor[idx + 2] - codes_2[j * 4 + 1] = frame_tensor[idx + 3] - codes_2[j * 4 + 2] = frame_tensor[idx + 5] - codes_2[j * 4 + 3] = frame_tensor[idx + 6] - codes = [codes_0.unsqueeze(0), codes_1.unsqueeze(0), codes_2.unsqueeze(0)] - if (torch.any(codes[0] < 0) or torch.any(codes[0] > 4096) or - torch.any(codes[1] < 0) or torch.any(codes[1] > 4096) or - torch.any(codes[2] < 0) or torch.any(codes[2] > 4096)): - return None - stream_ctx = torch.cuda.stream(cuda_stream) if cuda_stream is not None else torch.no_grad() - with stream_ctx, torch.inference_mode(): - audio_hat = snac_model.decode(codes) - audio_slice = audio_hat[:, :, 2048:4096] - if snac_device == "cuda": - audio_int16_tensor = (audio_slice * 32767).to(torch.int16) - audio_bytes = audio_int16_tensor.cpu().numpy().tobytes() - else: - audio_np = audio_slice.detach().cpu().numpy() - audio_int16 = (audio_np * 32767).astype(np.int16) - audio_bytes = audio_int16.tobytes() - return audio_bytes - -def turn_token_into_id(token_string, index): - token_string = token_string.strip() - if ""): - return None - try: - number_str = token_string[last_token_start + 14:-1] - return int(number_str) - 10 - ((index % 7) * 4096) - except (ValueError, IndexError): - return None - -token_cache = {} -MAX_CACHE_SIZE = 1000 - -def tokens_decoder(token_gen): - buffer = [] - count = 0 - min_frames_required = 28 - process_every = 7 - for token_text in token_gen: - cache_key = (token_text, count % 7) - if cache_key in token_cache: - token = token_cache[cache_key] - else: - token = turn_token_into_id(token_text, count) - if token is not None and len(token_cache) < MAX_CACHE_SIZE: - token_cache[cache_key] = token - if token is not None and token > 0: - buffer.append(token) - count += 1 - if count % process_every == 0 and count >= min_frames_required: - buffer_to_proc = buffer[-min_frames_required:] - audio_samples = convert_to_audio(buffer_to_proc, count) - if audio_samples is not None: - yield audio_samples - -def tokens_decoder_sync(syn_token_gen): - audio_segments = list(tokens_decoder(syn_token_gen)) - return b"".join(audio_segments) diff --git a/modules/stt_manager.py b/modules/stt_manager.py new file mode 100644 index 0000000..952c084 --- /dev/null +++ b/modules/stt_manager.py @@ -0,0 +1,245 @@ +# modules/stt_manager.py + +import time +import warnings +# Make sure Any is imported here +from typing import Optional, Tuple, Any # <--- MODIFIED LINE + +import numpy as np +from faster_whisper import WhisperModel +from faster_whisper.transcribe import Segment, TranscriptionInfo + +# Use logger, config manager, and performance monitor +try: + from .log_manager import logger + from .config_manager import get_setting + # Assuming PerformanceMonitor might be passed in or accessed globally/via context + # For simplicity here, we might instantiate or expect it if needed frequently + from .performance_monitor import PerformanceMonitor # Or get instance +except ImportError: + import logging + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log/config/perf managers. Using defaults.") + # Mock get_setting + def get_setting(key_path: str, default: Optional[Any] = None) -> Any: + defaults = { + "stt.model_size": "tiny.en", + "stt.device": "cpu", + "stt.compute_type": "int8", + "stt.language": None, + "stt.beam_size": 5, + } + return defaults.get(key_path, default) + # Mock PerformanceMonitor if needed for standalone testing + class MockPerformanceMonitor: + def start_timer(self, name): pass + def stop_timer(self, name, record_count=True): pass + def record_event(self, name, count=1.0): pass + performance_monitor = MockPerformanceMonitor() # Example instantiation + + +# --- Constants --- +EXPECTED_SAMPLE_RATE = 16000 # Whisper models expect 16kHz audio + +class STTError(Exception): + """Custom exception for STT related errors.""" + pass + + +class STTManager: + """Handles Speech-to-Text conversion using Faster Whisper.""" + + def __init__(self, performance_monitor: Optional[PerformanceMonitor] = None): # <-- Also updated to use actual class if imported + """ + Initializes the STTManager by loading the Faster Whisper model. + + Args: + performance_monitor: An instance of PerformanceMonitor (optional). + """ + logger.info("Initializing STTManager...") + # Use actual PerformanceMonitor type if available + self._perf_monitor: Optional[PerformanceMonitor] = performance_monitor + + self._model_size: str = get_setting("stt.model_size", "base.en") + self._device: str = get_setting("stt.device", "cpu") + self._compute_type: str = get_setting("stt.compute_type", "int8") + self._language: Optional[str] = get_setting("stt.language", None) + self._beam_size: int = get_setting("stt.beam_size", 5) + # Add other faster-whisper options from config if needed (e.g., VAD filter) + # self._vad_filter: bool = get_setting("stt.vad_filter", False) + # self._vad_parameters: dict = get_setting("stt.vad_parameters", {}) + + self._model: Optional[WhisperModel] = None + self._load_model() + + def _load_model(self): + """Loads the Faster Whisper model based on configuration.""" + logger.info( + "Loading Faster Whisper model: %s (Device: %s, Compute: %s)", + self._model_size, self._device, self._compute_type + ) + if self._perf_monitor: + self._perf_monitor.start_timer("stt_model_load_time") + + # Suppress specific warnings from CTranslate2 or dependencies if needed + # warnings.filterwarnings("ignore", category=UserWarning, module='torch.nn.functional') + + try: + self._model = WhisperModel( + model_size_or_path=self._model_size, + device=self._device, + compute_type=self._compute_type, + # Pass other options directly if needed: + # download_root=None, + # local_files_only=False, + # num_workers=1, # For multi-GPU, adjust if necessary + # cpu_threads=0 # 0 for auto + ) + logger.info("Faster Whisper model loaded successfully.") + if self._perf_monitor: + self._perf_monitor.stop_timer("stt_model_load_time", record_count=False) + self._perf_monitor.set_value("stt_model", f"{self._model_size} ({self._compute_type})") + + except ImportError as e: + logger.error("ImportError loading model. Ensure ctranslate2 and necessary CUDA libs are installed correctly: %s", e, exc_info=True) + raise STTError(f"Failed to load STT model due to missing dependency: {e}") from e + except RuntimeError as e: + logger.error("RuntimeError loading model. Check CUDA/cuDNN compatibility or model files: %s", e, exc_info=True) + raise STTError(f"Failed to load STT model: {e}") from e + except Exception as e: + logger.error("Unexpected error loading STT model: %s", e, exc_info=True) + if self._perf_monitor: + # Ensure timer is stopped even on error, don't record count + try: + self._perf_monitor.stop_timer("stt_model_load_time", record_count=False) + except Exception as timer_e: # Avoid masking original error + logger.error("Error stopping performance timer during STT load error: %s", timer_e) + raise STTError(f"Unexpected error loading STT model: {e}") from e + + + def transcribe_audio( + self, + audio_data: np.ndarray, + sample_rate: int + ) -> Tuple[str, Optional[str], Optional[float]]: + """ + Transcribes the given audio data to text. + + Args: + audio_data: NumPy array containing audio data (float32, mono). + sample_rate: The sample rate of the audio data. + + Returns: + A tuple containing: + - The transcribed text (str). + - The detected language code (str, optional). + - The language detection probability (float, optional). + Returns ("", None, None) if transcription fails or no speech is detected. + + Raises: + STTError: If the STT model is not loaded or transcription fails unexpectedly. + """ + if self._model is None: + logger.error("STT model is not loaded. Cannot transcribe.") + raise STTError("STT model not loaded.") + + if audio_data is None or audio_data.size == 0: + logger.warning("Attempted to transcribe empty audio data.") + return "", None, None + + if audio_data.dtype != np.float32: + logger.warning("STT received audio data that is not float32 (%s). Attempting conversion.", audio_data.dtype) + try: + # Ensure conversion respects potential integer range if input was int + if np.issubdtype(audio_data.dtype, np.integer): + max_val = np.iinfo(audio_data.dtype).max + audio_data = audio_data.astype(np.float32) / max_val + else: # Assume it's some other float type or bool maybe? + audio_data = audio_data.astype(np.float32) + except Exception as e: + logger.error("Failed to convert audio data to float32 for STT: %s", e) + if self._perf_monitor: self._perf_monitor.record_event("errors", 1) + return "", None, None # Indicate failure + + if sample_rate != EXPECTED_SAMPLE_RATE: + # This shouldn't happen if AudioManager resamples correctly, but good to check. + logger.warning( + "STT received audio with sample rate %d Hz, but expected %d Hz. " + "Transcription quality may be affected. Consider resampling in AudioManager.", + sample_rate, EXPECTED_SAMPLE_RATE + ) + # NOTE: faster-whisper *might* handle resampling internally via PyAV, + # but relying on the correct input rate is safer. + + logger.info("Starting audio transcription...") + if self._perf_monitor: + self._perf_monitor.start_timer("stt_transcription_time") + self._perf_monitor.record_event("stt_requests") + + full_text = "" + detected_language = None + lang_probability = None + + try: + # Faster Whisper's transcribe method takes the audio directly + segments, info = self._model.transcribe( + audio=audio_data, # Pass the NumPy array + language=self._language, # Use configured language or None for auto-detect + beam_size=self._beam_size, + # Add other relevant options from config: + # initial_prompt=None, + # word_timestamps=False, # Set to True if needed later + # vad_filter=self._vad_filter, + # vad_parameters=self._vad_parameters, + # task="transcribe" # or "translate" + ) + + # Process the generator to get the full text + segment_list = [] + # Use a loop instead of list comprehension for clearer segment logging + start_time_segments = time.monotonic() + segment_count = 0 + for segment in segments: + segment_list.append(segment.text) + segment_count += 1 + # Log segments as they arrive at DEBUG level if needed + logger.debug("[STT Segment %d] %.2fs -> %.2fs : %s", + segment_count, segment.start, segment.end, segment.text) + + # Check if any segments were produced + if not segment_list: + logger.info("Transcription yielded no segments (likely silence or non-speech).") + full_text = "" + # info object might still be useful, e.g., for duration + detected_language = info.language + lang_probability = info.language_probability + duration_sec = info.duration + else: + full_text = "".join(segment_list).strip() + detected_language = info.language + lang_probability = info.language_probability + duration_sec = info.duration # Total audio duration processed by whisper + + + logger.info( + "Transcription complete (Audio duration: %.2fs, Detected Lang: %s, Prob: %.2f)", + duration_sec, detected_language, lang_probability + ) + # Log full text at INFO, maybe truncate if very long for clarity + log_text = (full_text[:100] + '...') if len(full_text) > 100 else full_text + logger.info("Transcription Result: \"%s\"", log_text if log_text else "[No speech detected]") + + + except Exception as e: + logger.error("Error during audio transcription: %s", e, exc_info=True) + if self._perf_monitor: self._perf_monitor.record_event("errors", 1) + # Return empty text on error, but could raise STTError if preferred + return "", None, None + finally: + if self._perf_monitor: + try: + self._perf_monitor.stop_timer("stt_transcription_time") # Always stop timer + except Exception as timer_e: + logger.error("Error stopping performance timer after STT transcription: %s", timer_e) + + return full_text, detected_language, lang_probability \ No newline at end of file diff --git a/modules/token_decoder.py b/modules/token_decoder.py new file mode 100644 index 0000000..a1c623a --- /dev/null +++ b/modules/token_decoder.py @@ -0,0 +1,274 @@ +# modules/token_decoder.py + +import time +from typing import Iterable, Optional, List, Generator, Dict, Tuple, Any +import logging + +import numpy as np +import torch +# Ensure snac is installed, handle potential ImportError +try: + from snac import SNAC +except ImportError: + SNAC = None + +# Use logger +try: + from .log_manager import logger +except ImportError: + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log_manager. Using default logger for token_decoder.") + + +# --- SNAC Model Loading (Done once at import time) --- +_snac_model: Optional[Any] = None +_snac_device: Optional[str] = None +_cuda_stream: Optional[torch.cuda.Stream] = None +_is_snac_initialized: bool = False + +def initialize_snac(): + """Initializes the SNAC model and determines the device.""" + global _snac_model, _snac_device, _cuda_stream, _is_snac_initialized + if _is_snac_initialized: + return + + if SNAC is None: + logger.error("SNAC library is not installed. TTS Token decoding will not work.") + _is_snac_initialized = True # Mark as initialized (but failed) + return + + try: + logger.info("Initializing SNAC model for TTS token decoding...") + # Check device availability + if torch.cuda.is_available(): + _snac_device = "cuda" + _cuda_stream = torch.cuda.Stream() + else: + _snac_device = "cpu" + _cuda_stream = None + logger.info("Using SNAC on device: %s", _snac_device) + + # Load the pre-trained SNAC model + snac_model_name = "hubertsiuzdak/snac_24khz" + # Suppress the specific FutureWarning from torch.load within SNAC if desired + import warnings + warnings.filterwarnings("ignore", message="You are using `torch.load` with `weights_only=False`", category=FutureWarning) + _snac_model = SNAC.from_pretrained(snac_model_name).eval() + warnings.resetwarnings() # Optional: Reset warnings filters after loading + _snac_model = _snac_model.to(_snac_device) + logger.info("SNAC model '%s' loaded successfully.", snac_model_name) + _is_snac_initialized = True + + except Exception as e: + logger.error("Failed to initialize SNAC model: %s", e, exc_info=True) + _snac_model = None # Ensure model is None on failure + _is_snac_initialized = True # Mark as initialized (but failed) + +# --- Run initialization --- +initialize_snac() + +# --- Constants (Matching Original Logic) --- +TOKENS_PER_AUDIO_FRAME = 7 +SNAC_EXPECTED_RATE = 24000 +AUDIO_SLICE_START = 2048 +AUDIO_SLICE_END = 4096 +MAX_TOKEN_ID = 4096 # For validation bounds, NOT used in modulo calculation here +MIN_FRAMES_REQUIRED = 4 # Minimum number of frames (7 tokens each) needed before processing +PROCESS_CHUNK_FRAMES = 1 # How many new frames trigger processing (usually 1) +PROCESS_WINDOW_FRAMES = MIN_FRAMES_REQUIRED # How many frames (x7 tokens) to pass to decode func +TOKEN_CACHE: Dict[Tuple[str, int], Optional[int]] = {} +MAX_CACHE_SIZE = 10000 +TOKEN_ID_OFFSET = 10 # Offset from the original formula + +# --- Core Decoding Functions --- + +def _original_convert_to_audio(multiframe: List[int], count: int) -> Optional[bytes]: + """ + Identical logic to the original project's convert_to_audio. + Accepts a list of token IDs (expects PROCESS_WINDOW_FRAMES * 7 = 28). + """ + if _snac_model is None or _snac_device is None: + logger.error("SNAC model not initialized."); return None + + required_tokens = PROCESS_WINDOW_FRAMES * TOKENS_PER_AUDIO_FRAME + if len(multiframe) < required_tokens: + logger.warning("_original_convert_to_audio needs %d tokens, got %d.", required_tokens, len(multiframe)) + return None + + # Process the required window size + frame_tokens = multiframe[:required_tokens] + num_frames = PROCESS_WINDOW_FRAMES # Should be 4 + + logger.debug("Original logic: Decoding batch of %d frames (%d tokens)...", num_frames, len(frame_tokens)) + + try: + # Prepare tensors for the batch of frames + codes_0 = torch.zeros(num_frames, dtype=torch.int32, device=_snac_device) + codes_1 = torch.zeros(num_frames * 2, dtype=torch.int32, device=_snac_device) + codes_2 = torch.zeros(num_frames * 4, dtype=torch.int32, device=_snac_device) + # Use torch.tensor directly on the list slice for efficiency + frame_tensor = torch.tensor(frame_tokens, dtype=torch.int32, device=_snac_device) + + # Populate code tensors using loops (as in original) + for j in range(num_frames): + idx = j * TOKENS_PER_AUDIO_FRAME + codes_0[j] = frame_tensor[idx] + codes_1[j * 2] = frame_tensor[idx + 1] + codes_1[j * 2 + 1] = frame_tensor[idx + 4] + codes_2[j * 4] = frame_tensor[idx + 2] + codes_2[j * 4 + 1] = frame_tensor[idx + 3] + codes_2[j * 4 + 2] = frame_tensor[idx + 5] + codes_2[j * 4 + 3] = frame_tensor[idx + 6] + + codes = [codes_0.unsqueeze(0), codes_1.unsqueeze(0), codes_2.unsqueeze(0)] # Add batch dim + + except (ValueError, TypeError, IndexError) as e: + logger.error("Tensor creation failed (Original logic): %s", e) + return None + + # --- RE-ENABLE VALIDATION - LOGGING ONLY --- + # Check if calculated IDs (which might be negative) fall outside a "reasonable" range + # if SNAC is expected to handle them internally. Let's check against 0 and MAX_TOKEN_ID (4096). + validation_passed = True + min_val_0, max_val_0 = torch.min(codes[0]).item(), torch.max(codes[0]).item() + min_val_1, max_val_1 = torch.min(codes[1]).item(), torch.max(codes[1]).item() + min_val_2, max_val_2 = torch.min(codes[2]).item(), torch.max(codes[2]).item() + + if min_val_0 < 0 or max_val_0 > MAX_TOKEN_ID: + logger.warning("Validation FAIL (Log Only): codes_0 out of range [0, %d]. Min: %d, Max: %d", MAX_TOKEN_ID, min_val_0, max_val_0) + validation_passed = False + if min_val_1 < 0 or max_val_1 > MAX_TOKEN_ID: + logger.warning("Validation FAIL (Log Only): codes_1 out of range [0, %d]. Min: %d, Max: %d", MAX_TOKEN_ID, min_val_1, max_val_1) + validation_passed = False + if min_val_2 < 0 or max_val_2 > MAX_TOKEN_ID: + logger.warning("Validation FAIL (Log Only): codes_2 out of range [0, %d]. Min: %d, Max: %d", MAX_TOKEN_ID, min_val_2, max_val_2) + validation_passed = False + + if not validation_passed: + logger.debug("Problematic batch tokens for validation fail: %s", frame_tokens) + logger.warning("Proceeding with potentially invalid tokens to debug CUDA assert...") + # else: + # logger.debug("Token ID validation passed (check based on [0, %d]).", MAX_TOKEN_ID) + + # Perform decoding + audio_bytes = None + try: + stream_ctx = torch.cuda.stream(_cuda_stream) if _cuda_stream is not None else torch.no_grad() + with stream_ctx, torch.inference_mode(): + start_decode_time = time.monotonic() + # --- Call SNAC decode --- + audio_hat = _snac_model.decode(codes) + decode_duration_ms = (time.monotonic() - start_decode_time) * 1000 + logger.debug("SNAC batch decode (Original logic) took %.2f ms", decode_duration_ms) + + # --- USE ORIGINAL SLICE --- + audio_slice = audio_hat[:, :, AUDIO_SLICE_START:AUDIO_SLICE_END] + if logger.isEnabledFor(logging.DEBUG): # Avoid potentially expensive tensor ops if not logging + logger.debug("Original logic slice shape: %s, min=%.4f, max=%.4f", + audio_slice.shape, torch.min(audio_slice).item(), torch.max(audio_slice).item()) + + if audio_slice.numel() == 0: + logger.warning("audio_slice is empty after decoding!") + return None + + # --- Conversion to bytes --- + if _snac_device == "cuda": + audio_int16_tensor = (audio_slice * 32767.0).clamp(-32768.0, 32767.0).to(torch.int16) + audio_bytes = audio_int16_tensor.cpu().numpy().tobytes() + else: # CPU + audio_np = audio_slice.detach().numpy() + audio_int16 = (audio_np * 32767.0).clip(-32768.0, 32767.0).astype(np.int16) + audio_bytes = audio_int16.tobytes() + + logger.debug("Batch successfully converted to %d bytes.", len(audio_bytes) if audio_bytes else 0) + return audio_bytes + + except Exception as e: + # Catch CUDA errors here specifically if possible + logger.error("Error during SNAC decoding step (Original logic): %s", e, exc_info=True) + return None + +# --- Reverted to ORIGINAL formula --- +def _turn_token_into_id(token_string: str, index: int) -> Optional[int]: + """Parses custom token string using the original formula.""" + token_string = token_string.strip() + if ""): return None + try: + number_str = token_string[last_token_start + 14:-1] + # Original formula: + token_id = int(number_str) - TOKEN_ID_OFFSET - ((index % TOKENS_PER_AUDIO_FRAME) * MAX_TOKEN_ID) + # logger.debug("Original Formula: Raw %s -> ID %d (Index %d)", number_str, token_id, index) + return token_id + except (ValueError, IndexError): + logger.warning("Failed parse token ID (Original): '%s'", token_string); return None + + +# --- Generator using IDENTICAL logic to original tokens_decoder --- +def decode_tts_tokens(token_gen: Iterable[str]) -> Generator[bytes, None, None]: + """Identical logic to the original project's tokens_decoder generator.""" + if not _is_snac_initialized or _snac_model is None: + logger.error("SNAC model not available."); return + + buffer: List[int] = [] + count = 0 + min_tokens_required = MIN_FRAMES_REQUIRED * TOKENS_PER_AUDIO_FRAME # 28 + process_every_tokens = PROCESS_CHUNK_FRAMES * TOKENS_PER_AUDIO_FRAME # 7 + process_window_tokens = PROCESS_WINDOW_FRAMES * TOKENS_PER_AUDIO_FRAME # 28 + + processed_stream_tokens = 0 + yielded_chunks = 0 + logger.debug("Starting TTS token decoding stream (Strict Original Logic)...") + + for token_text in token_gen: + processed_stream_tokens += 1 + if processed_stream_tokens % 100 == 0: + logger.debug("Processing token #%d from stream: %s...", processed_stream_tokens, token_text[:30]) + + # --- Get Token ID (Original Formula) --- + # Use 'count' for index as in original logic + cache_key = (token_text, count % TOKENS_PER_AUDIO_FRAME) + if cache_key in TOKEN_CACHE: + token_id = TOKEN_CACHE[cache_key] + else: + token_id = _turn_token_into_id(token_text, count) # Pass current count as index + if len(TOKEN_CACHE) < MAX_CACHE_SIZE: + TOKEN_CACHE[cache_key] = token_id # Cache result (including None) + + # --- USE ORIGINAL > 0 CHECK --- + if token_id is not None and token_id > 0: + buffer.append(token_id) + count += 1 # Increment count *only* for valid tokens > 0 added + + # --- Original Condition to Process --- + if count % process_every_tokens == 0 and count >= min_tokens_required: + buffer_to_proc = buffer[-process_window_tokens:] # Last 28 tokens + logger.debug("Processing original window (count=%d, buffer_len=%d, window_len=%d)", + count, len(buffer), len(buffer_to_proc)) + + # Call the function with original logic structure + audio_samples = _original_convert_to_audio(buffer_to_proc, count) + + if audio_samples is not None: + yield audio_samples + yielded_chunks += 1 + if yielded_chunks % 5 == 0: # Log every 5 batches yielded + logger.debug("Yielded audio batch #%d (Original Logic)", yielded_chunks) + # else: + # Optionally log if token_id was None or <= 0 + # logger.debug("Skipping token: ID=%s", token_id) + + + logger.debug("Finished consuming token stream (%d total tokens processed).", processed_stream_tokens) + logger.debug("Finished TTS token decoding stream (%d total audio batches yielded).", yielded_chunks) + + +# decode_tts_tokens_to_bytes remains the same +def decode_tts_tokens_to_bytes(token_stream: Iterable[str]) -> bytes: + """Decodes token stream and concatenates audio bytes.""" + if not _is_snac_initialized or _snac_model is None: return b"" + # Use the generator version which now incorporates the original logic + audio_segments = list(decode_tts_tokens(token_stream)) + if not audio_segments: logger.warning("Token decoding yielded no audio segments."); return b"" + return b"".join(audio_segments) \ No newline at end of file diff --git a/modules/tts_manager.py b/modules/tts_manager.py new file mode 100644 index 0000000..fa15c89 --- /dev/null +++ b/modules/tts_manager.py @@ -0,0 +1,298 @@ +# modules/tts_manager.py + +import os +import re +import time +import json +import wave +from pathlib import Path +from typing import Optional, Generator, List, Dict, Any, Tuple +import logging # Correctly imported + +import requests +import numpy as np + +# Use logger, config manager, performance monitor, and token decoder +try: + from .log_manager import logger + from .config_manager import get_setting + from .performance_monitor import PerformanceMonitor + from .token_decoder import decode_tts_tokens_to_bytes, SNAC_EXPECTED_RATE, _is_snac_initialized as is_snac_ready + from .audio_manager import AudioManager +except ImportError: + # import logging # Already imported above + logger = logging.getLogger(__name__) + logger.warning("Could not import custom log/config/perf/token/audio managers. Using defaults.") + # Mock dependencies... (Assuming mocks are correctly defined as before) + def get_setting(key_path: str, default: Optional[Any] = None) -> Any: + # ... (mock implementation) ... + pass + class MockPerformanceMonitor: # ... (mock implementation) ... + pass + def decode_tts_tokens_to_bytes(stream): return b"" + SNAC_EXPECTED_RATE = 24000 + is_snac_ready = True + class MockAudioManager: # ... (mock implementation) ... + @staticmethod + def save_wave(filepath, audio_data, sample_rate): pass + AudioManager = MockAudioManager + + +# --- Constants --- +TTS_OUTPUT_FILENAME_FORMAT = "{voice}_{timestamp}.wav" +COMBINED_FILENAME_FORMAT = "{voice}_{timestamp}_combined.wav" + +class TTSError(Exception): + """Custom exception for TTS related errors.""" + pass + + +def _clean_text_for_tts(text: str) -> str: + """Cleans text before sending to the TTS engine.""" + # ... (Implementation remains the same) ... + text = str(text); text = text.replace('\n', ' '); text = re.sub(r'\*+', '', text) + text = re.sub(r'[^\x00-\x7F]+', '', text); text = re.sub(r'\s+', ' ', text) + return text.strip() + + +def _segment_text(text: str, max_words: int) -> List[str]: + """Splits text into segments, trying to respect sentence boundaries.""" + # ... (Implementation remains the same) ... + if not text: return [] + if max_words <= 0: return [text] + words = text.split(); + if len(words) <= max_words: return [text] + segments = []; current_segment_words: List[str] = [] + sentence_ending_punctuation = (".", "!", "?", ";", ":", ".\"","!\"","?\"") + min_segment_len_factor = 0.1; merge_overshoot_factor = 1.2 + for word in words: + current_segment_words.append(word) + word_ends_sentence = any(word.endswith(p) for p in sentence_ending_punctuation) + current_length = len(current_segment_words) + if current_length >= max_words or \ + (word_ends_sentence and current_length > max_words * 0.6): + segments.append(" ".join(current_segment_words)); current_segment_words = [] + if current_segment_words: segments.append(" ".join(current_segment_words)) + merged_segments: List[str] = []; i = 0 + while i < len(segments): + current = segments[i]; current_len = len(current.split()) + if i == len(segments) - 1 or \ + len(segments[i+1].split()) > max_words * min_segment_len_factor or \ + current_len > max_words * min_segment_len_factor: + merged_segments.append(current); i += 1 + else: + next_segment = segments[i+1]; merged = current + " " + next_segment; merged_len = len(merged.split()) + if merged_len <= max_words * merge_overshoot_factor: + merged_segments.append(merged); logger.debug("Merged short segment."); i += 2 + else: merged_segments.append(current); i += 1 + if not merged_segments and segments: return segments + logger.debug("Segmented text into %d parts.", len(merged_segments)) + return merged_segments + + +class TTSManager: + """Handles Text-to-Speech synthesis using an LLM endpoint and SNAC decoding.""" + + def __init__(self, performance_monitor: Optional[PerformanceMonitor] = None): + # ... (Initialization remains the same) ... + logger.info("Initializing TTSManager...") + self._perf_monitor = performance_monitor + if not is_snac_ready: raise TTSError("SNAC model failed init or not found.") + self._base_url: str = get_setting("llm.base_url", "...").rstrip('/') + self._timeout: float = get_setting("llm.request_timeout_sec", 120.0) + self._max_retries: int = get_setting("llm.max_retries", 2) + self._tts_endpoint: str = get_setting("tts.endpoint", "/completions").lstrip('/') + self._tts_model: str = get_setting("tts.model", "orpheus-model") + self._default_voice: str = get_setting("tts.default_voice", "tara") + self._tts_max_tokens: int = get_setting("tts.max_tokens", 4096) + self._tts_temperature: float = get_setting("tts.temperature", 0.6) + self._tts_top_p: float = get_setting("tts.top_p", 0.9) + self._tts_repetition_penalty: float = get_setting("tts.repetition_penalty", 1.0) + self._tts_speed: float = get_setting("tts.speed", 1.0) + self._output_dir = Path(get_setting("tts.output_dir", "outputs")) + self._clear_output: bool = get_setting("tts.clear_output_on_start", True) + self._segment_max_words: int = get_setting("tts.segmentation.max_words_per_segment", 60) + self._session = requests.Session() + self._headers = {"Content-Type": "application/json", "Accept": "text/event-stream"} + self._tts_url = f"{self._base_url}/{self._tts_endpoint}" + self._prepare_output_directory() + logger.info("TTSManager configured for URL: %s", self._tts_url) + logger.info("TTS Model: %s | Default Voice: %s", self._tts_model, self._default_voice) + + def _prepare_output_directory(self): + """Creates the output directory and optionally clears it.""" + # ... (Implementation remains the same) ... + try: + self._output_dir.mkdir(parents=True, exist_ok=True) + logger.info("TTS output directory: %s", self._output_dir.resolve()) + if self._clear_output: + logger.info("Clearing previous TTS output files from %s...", self._output_dir) + count = 0; deleted_files = [] + for item in self._output_dir.glob('*.wav'): + try: item.unlink(); deleted_files.append(item.name); count += 1 + except OSError as e: logger.warning("Could not delete %s: %s", item, e) + if count > 0: logger.debug("Cleared files: %s", ", ".join(deleted_files)) + logger.info("Cleared %d previous WAV files.", count) + except Exception as e: logger.error("Failed prepare output dir '%s': %s", self._output_dir, e); raise TTSError(f"Output dir error: {e}") from e + + # --- synthesize_speech with Segmentation --- + def synthesize_speech( + self, + text: str, + voice: Optional[str] = None + ) -> Tuple[Optional[str], Optional[np.ndarray], int]: + """Synthesizes speech, handling segmentation for long text.""" + if not text: logger.warning("Synthesize speech called with empty text."); return None, None, SNAC_EXPECTED_RATE + + active_voice = voice if voice else self._default_voice + if not re.fullmatch(r'[a-zA-Z0-9_-]+', active_voice): + logger.warning("Invalid voice tag '%s'. Using default '%s'.", active_voice, self._default_voice); active_voice = self._default_voice + + cleaned_text = _clean_text_for_tts(text) + if not cleaned_text: logger.warning("Text empty after cleaning: '%s'", text); return None, None, SNAC_EXPECTED_RATE + + segments = _segment_text(cleaned_text, self._segment_max_words) + if len(segments) > 1: logger.info("Text is long, processing %d segments...", len(segments)) + else: logger.info("Processing single text segment...") + + segment_audio_data: List[np.ndarray] = [] + segment_files: List[Path] = [] + total_success = True + + # --- Process Segments --- + for i, segment_text in enumerate(segments): + logger.info("Synthesizing segment %d/%d...", i + 1, len(segments)) + if logger.isEnabledFor(logging.DEBUG): # Correct check using imported logging + seg_preview = (segment_text[:60] + '...') if len(segment_text) > 60 else segment_text + logger.debug("Segment text: \"%s\"", seg_preview) + + if self._perf_monitor: self._perf_monitor.start_timer("tts_synthesis_time"); self._perf_monitor.record_event("tts_requests") + audio_bytes: Optional[bytes] = None + try: + audio_bytes = self._synthesize_segment(segment_text, active_voice) + if audio_bytes: + if len(audio_bytes) % 2 != 0: logger.warning("Odd bytes (%d) seg %d. Trimming.", len(audio_bytes), i+1); audio_bytes = audio_bytes[:-1] + if not audio_bytes: logger.warning("Audio empty post-trim seg %d.", i+1); continue + audio_segment_np = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32767.0 + segment_audio_data.append(audio_segment_np) + duration_sec = len(audio_segment_np) / SNAC_EXPECTED_RATE + logger.info("Segment %d synthesized successfully (%.2f seconds).", i + 1, duration_sec) + if len(segments) > 1: + timestamp = int(time.time_ns() // 1_000_000) + seg_filename = self._output_dir / f"{active_voice}_{timestamp}_seg{i}.wav" + try: AudioManager.save_wave(str(seg_filename), audio_segment_np, SNAC_EXPECTED_RATE) + except Exception as save_e: logger.error("Failed save segment %s: %s", seg_filename, save_e) + segment_files.append(seg_filename) + else: logger.error("Failed synthesize segment %d.", i + 1); total_success = False; break + except TTSError as e: logger.error("TTS Error seg %d: %s", i + 1, e); total_success = False; break + except Exception as e: logger.error("Unexpected Error seg %d: %s", i + 1, e, exc_info=True); total_success = False; break + finally: + if self._perf_monitor: + try: self._perf_monitor.stop_timer("tts_synthesis_time") + # CORRECTED SYNTAX: except Exception as t_e: + except Exception as t_e: logger.error("Timer error: %s", t_e) + if len(segments) > 1 and i < len(segments) - 1: time.sleep(0.2) + + # --- Combine & Save --- + if not total_success or not segment_audio_data: + logger.error("TTS synthesis failed. No audio generated."); + for f in segment_files: + try: + f.unlink() + except OSError: + pass + return None, None, SNAC_EXPECTED_RATE + try: final_audio_data = np.concatenate(segment_audio_data) + except ValueError as e: logger.error("Failed concat segments: %s", e); return None, None, SNAC_EXPECTED_RATE + if final_audio_data.size == 0: logger.error("Concatenated audio data is empty."); return None, None, SNAC_EXPECTED_RATE + + timestamp = int(time.time()) + is_multi_segment = len(segments) > 1 + output_filename = (COMBINED_FILENAME_FORMAT if is_multi_segment else TTS_OUTPUT_FILENAME_FORMAT).format(voice=active_voice, timestamp=timestamp) + output_filepath = self._output_dir / output_filename + try: AudioManager.save_wave(str(output_filepath), final_audio_data, SNAC_EXPECTED_RATE) + # CORRECTED SYNTAX: except Exception as e: + except Exception as e: logger.error("Failed save final TTS %s: %s", output_filepath, e) + if is_multi_segment: + logger.debug("Cleaning up %d intermediate segment files...", len(segment_files)) + for f in segment_files: + try: f.unlink() + # CORRECTED SYNTAX: except OSError as e: + except OSError as e: logger.warning("Could not delete temp %s: %s", f, e) + return str(output_filepath), final_audio_data, SNAC_EXPECTED_RATE + + # --- _synthesize_segment --- + def _synthesize_segment(self, text_segment: str, voice: str) -> Optional[bytes]: + """Sends a single text segment to the TTS API and returns decoded audio bytes.""" + prompt = f"<|audio|>{voice}: {text_segment}<|eot_id|>" + payload = { + "model": self._tts_model, "prompt": prompt, "max_tokens": self._tts_max_tokens, + "temperature": self._tts_temperature, "top_p": self._tts_top_p, + "repeat_penalty": self._tts_repetition_penalty, "speed": self._tts_speed, + "stream": True + } + log_payload = payload.copy() + log_payload["prompt"] = (prompt[:50] + "...") if len(prompt) > 50 else prompt + logger.debug("TTS Request Payload: %s", json.dumps(log_payload)) + + audio_bytes: Optional[bytes] = None + response: Optional[requests.Response] = None + + for attempt in range(self._max_retries + 1): + should_retry = False + try: + response = self._session.post(self._tts_url, headers=self._headers, json=payload, stream=True, timeout=self._timeout) + response.raise_for_status() + def token_generator() -> Generator[str, None, None]: + nonlocal response + resp_to_close = response + try: + if resp_to_close is None: logger.error("BUG: token_generator started with None response"); return + logger.debug("Reading token stream from response...") + lines_processed = 0 + for line in resp_to_close.iter_lines(): + lines_processed += 1 + if not line: continue + decoded_line = line.decode("utf-8") + if decoded_line.startswith("data: "): + data_str = decoded_line[len("data: "):].strip() + if data_str == "[DONE]": logger.debug("SSE [DONE] received."); break + try: data = json.loads(data_str); token_text = data.get("choices", [{}])[0].get("text", "") + except (json.JSONDecodeError, IndexError, KeyError): logger.warning("Failed decode/parse SSE JSON: %s", data_str); continue + if token_text: yield token_text + logger.debug("Finished reading token stream (%d lines processed).", lines_processed) + except requests.exceptions.ChunkedEncodingError as chunk_err: logger.warning("Stream connection broken during read: %s", chunk_err) + # CORRECTED SYNTAX: except Exception as gen_err: + except Exception as gen_err: logger.error("Error reading token stream: %s", gen_err, exc_info=True) + finally: + if resp_to_close: + try: resp_to_close.close(); logger.debug("Closed response stream in generator finally.") + # CORRECTED SYNTAX: except Exception as close_e: + except Exception as close_e: logger.warning("Error closing response in generator: %s", close_e) + # response = None # Keep outer response to allow outer finally to close if needed + logger.debug("Starting token decoding for segment...") + start_decode_io = time.monotonic() + audio_bytes = decode_tts_tokens_to_bytes(token_generator()) + decode_io_duration = time.monotonic() - start_decode_io + logger.debug("Token decoding finished (%.2f sec). Got %d audio bytes.", decode_io_duration, len(audio_bytes) if audio_bytes else 0) + if audio_bytes: break + else: logger.warning("Decoder returned empty audio (Attempt %d/%d).", attempt + 1, self._max_retries + 1); should_retry = attempt < self._max_retries + except requests.exceptions.Timeout: logger.warning("TTS request timed out (Attempt %d/%d)", attempt + 1, self._max_retries + 1); should_retry = attempt < self._max_retries + except requests.exceptions.RequestException as e: logger.warning("TTS request failed (Attempt %d/%d): %s", attempt + 1, self._max_retries + 1, e); should_retry = attempt < self._max_retries + except Exception as e: logger.error("Unexpected error during TTS segment synthesis: %s", e, exc_info=True); break + finally: + if response: + try: response.close(); logger.debug("Closed response in outer finally.") + # CORRECTED SYNTAX: except Exception: pass + except Exception: pass + response = None # Mark as closed + + if should_retry: time.sleep(1 * (2 ** attempt)); logger.info("Retrying TTS segment synthesis...") + else: break + if not audio_bytes and ('e' not in locals() or isinstance(e, (requests.exceptions.Timeout, requests.exceptions.RequestException))): + raise TTSError(f"TTS segment synthesis failed after {self._max_retries + 1} attempts (no audio bytes decoded).") + return audio_bytes + + def close_session(self): + """Closes the underlying requests session.""" + logger.debug("Closing TTSManager session."); self._session.close() \ No newline at end of file diff --git a/modules/virtual_assistant.py b/modules/virtual_assistant.py deleted file mode 100644 index 6947bcf..0000000 --- a/modules/virtual_assistant.py +++ /dev/null @@ -1,138 +0,0 @@ -# modules/virtual_assistant.py - -import os -import time -import wave -import numpy as np -import sounddevice as sd -from typing import Optional -from modules.logging import logger -from modules.whisper_recognizer import WhisperRecognizer -from modules.lm_client import LMStudioClient -from modules.hotword_detector import HotwordDetector -from modules.performance import PerformanceMonitor -from modules.config import load_config - -class VirtualAssistant: - def __init__(self, config_path: str = "settings.yml"): - self.config = load_config(config_path) - self.recognizer = WhisperRecognizer( - model_name=self.config["whisper"]["model"], - sample_rate=self.config["whisper"]["sample_rate"], - config=self.config - ) - self.lm_client = LMStudioClient(self.config) - # Always initialize hotword detector if enabled in config - self.hotword_detector = HotwordDetector(config=self.config) if self.config["hotword"]["enabled"] else None - self.performance = PerformanceMonitor() - self._running = False - - def run(self): - self._running = True - logger.info("Assistant started. Press ENTER or say '%s' to interact.", self.config["hotword"]["phrase"]) - - try: - while self._running: - try: - if not self._wait_for_activation(): - continue - # Record and process user input - user_text = self.recognizer.transcribe() - if not user_text: - logger.warning("No speech detected.") - continue - # Get and process response - response_text = self.lm_client.chat(user_text) - self.performance.add_tokens(len(response_text.split())) - - # Synthesize speech - word_count = len(response_text.split()) - if word_count > self.config["segmentation"]["max_words"]: - logger.info("Response is long (%d words). Segmenting...", word_count) - output_file = self.lm_client.synthesize_long_text(response_text) - else: - output_file = self.lm_client.synthesize_speech(response_text) - - # Play audio and wait for the activation signal before next loop - self.play_audio(output_file) - self._wait_for_activation() # Wait again after audio playback - except Exception as e: - logger.error("Error in main loop: %s", str(e)) - time.sleep(1) - - except KeyboardInterrupt: - logger.info("Exiting assistant. Goodbye!") - finally: - try: - self.performance.report(force=True) - except Exception as e: - logger.error("Error in performance report: %s", str(e)) - self._running = False - - def stop(self): - self._running = False - - def _flush_stdin(self): - """Flush any lingering input from stdin.""" - try: - import sys - import termios - termios.tcflush(sys.stdin, termios.TCIFLUSH) - except Exception: - try: - import msvcrt - while msvcrt.kbhit(): - msvcrt.getch() - except Exception: - pass - - def _wait_for_activation(self) -> bool: - """ - Wait for activation either by detecting a keypress (push-to-talk) - or by detecting the hotword, whichever comes first. - """ - logger.info("Waiting for activation: press ENTER or say the hotword...") - hotword_timeout = self.config["hotword"]["timeout_sec"] if self.hotword_detector else 0 - elapsed = 0.0 - check_interval = 0.5 - while elapsed < hotword_timeout: - if self._check_for_keypress(): - self._flush_stdin() - return True - if self.hotword_detector and self.hotword_detector.check_for_hotword(timeout=check_interval): - return True - time.sleep(check_interval) - elapsed += check_interval - # Fallback to blocking push-to-talk input if neither hotword nor keypress detected within the timeout - input("Press ENTER to speak...") - return True - - def _check_for_keypress(self) -> bool: - """Non-blocking keypress check.""" - try: - import msvcrt # Windows - return msvcrt.kbhit() - except ImportError: - import sys - import select # Unix - return sys.stdin in select.select([sys.stdin], [], [], 0)[0] - - def play_audio(self, filename: str): - """Play audio with normalization and error handling.""" - if not os.path.exists(filename): - logger.error("Audio file not found: %s", filename) - return - try: - with wave.open(filename, "rb") as wf: - sample_rate = wf.getframerate() - audio_data = wf.readframes(wf.getnframes()) - audio_array = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32) / 32767.0 - if self.config["speech"]["normalize_audio"]: - max_val = np.max(np.abs(audio_array)) - if max_val > 0: - audio_array = audio_array / max_val - sd.play(audio_array, samplerate=sample_rate) - sd.wait() - except Exception as e: - logger.error("Audio playback error: %s", str(e)) - raise diff --git a/modules/whisper_recognizer.py b/modules/whisper_recognizer.py deleted file mode 100644 index b34f5db..0000000 --- a/modules/whisper_recognizer.py +++ /dev/null @@ -1,54 +0,0 @@ -# modules/whisper_recognizer.py -from typing import Optional -import os -import time -import torch -import numpy as np -import sounddevice as sd -from scipy.io.wavfile import write as wav_write -import tempfile -import whisper -from modules.logging import logger -from modules.audio import record_until_silence -from modules.config import load_config - -class WhisperRecognizer: - def __init__(self, model_name: str = "base", sample_rate: int = 16000, config=None): - self.config = config if config is not None else load_config() - logger.info("Loading Whisper model (%s)...", model_name) - device = "cuda" if torch.cuda.is_available() else "cpu" - self.model = whisper.load_model(model_name, device=device) - self.sample_rate = sample_rate - logger.info("Whisper model loaded on %s", device) - - def transcribe(self, device: Optional[int] = None) -> str: - """Record and transcribe audio with error handling""" - try: - logger.info("Recording...") - audio = record_until_silence( - self.sample_rate, - device=device or self.config["audio"]["input_device"] - ) - if audio.size == 0: - logger.warning("No audio recorded") - return "" - # Use a temporary file for audio - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - temp_file = tmp.name - wav_write(temp_file, self.sample_rate, audio) - logger.info("Transcribing...") - start_time = time.time() - result = self.model.transcribe(temp_file) - elapsed = time.time() - start_time - logger.debug("Transcription took %.2f seconds", elapsed) - text = result.get("text", "").strip() - if not text: - logger.warning("No speech detected in audio") - try: - os.remove(temp_file) - except Exception as e: - logger.warning("Could not delete temporary file %s: %s", temp_file, str(e)) - return text - except Exception as e: - logger.error("Transcription failed: %s", str(e)) - return "" diff --git a/morpheus.py b/morpheus.py index 3f2e27c..741c24d 100644 --- a/morpheus.py +++ b/morpheus.py @@ -1,30 +1,370 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +mOrpheus - A Voice Assistant Framework +""" + import argparse import sys -from modules.virtual_assistant import VirtualAssistant -from modules.logging import logger +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="settings.yml", - help="Path to configuration YAML file." - ) + 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) - logger.info("Starting mOrpheus virtual assistant...") - assistant.run() + 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: - logger.info("Assistant interrupted by user. Shutting down.") + 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: - logger.critical(f"Fatal error: {str(e)}", exc_info=True) - sys.exit(1) + 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) - logger.info("mOrpheus shutdown complete") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ca941bb..81a2800 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,49 @@ -# Install PyTorch with CUDA support as needed. -# For example, if using CUDA 12.6, run: -# pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126 +# requirements.txt -openai-whisper +# Core +PyYAML>=6.0 sounddevice>=0.4.6 -numpy>=1.24 +numpy>=1.26.0,<2.0 # Let's start below 2.0 for safety, pip might upgrade if needed/possible scipy>=1.10.0 -webrtcvad -requests>=2.31.0 -pyyaml>=6.0.1 -snac +requests>=2.30.0 + +# STT (Speech-to-Text - Faster Whisper) +faster-whisper>=1.0.0 +# Dependencies for faster-whisper: +ctranslate2>=4.0.0 # Check compatibility with your CUDA/cuDNN (See faster-whisper docs) +huggingface_hub>=0.13 +tokenizers>=0.13,<1 +# onnxruntime is listed below (shared dependency) +PyAV>=11.0.0 # Replaces ffmpeg requirement +tqdm # For progress bars during model download + +# Hotword Detection (OpenWakeWord) +openwakeword>=0.5.0 +# Dependencies for openwakeword: +# onnxruntime is listed below (shared dependency) +# numpy is listed above +# scipy is listed above +protobuf>=3.20.0,<5.0.0 # Common ONNX dependency, pin below 5 for safety +sounddevice # Already listed above + +# Shared Dependencies +onnxruntime-gpu>=1.17.0 # Use GPU version. Ensure version matches CUDA/cuDNN needs. Check ONNX Runtime docs. + +# VAD (Voice Activity Detection) +webrtcvad-wheels>=2.0.10.post2 # Keep for recording control + +# Console UI +rich>=13.7.0 + +# TTS Audio Decoding (SNAC) +# Assuming 'snac' is installed separately, e.g., from a specific source/repo +# If it's on PyPI: +# snac>=[version] +# torch is required by snac +torch>=2.0.0 --index-url https://download.pytorch.org/whl/cu121 # Example for CUDA 12.1, adjust for your 12.8 + +# NOTE: Adjust torch install URL based on your exact CUDA version (e.g., cu118, cu121, cpu) +# NOTE: For onnxruntime-gpu, ensure your CUDA and cuDNN versions are compatible. +# NOTE: For ctranslate2, ensure your CUDA and cuDNN versions are compatible (CUDA 12/cuDNN 9 preferred for latest). Check faster-whisper/CTranslate2 docs. May need pinning like: ctranslate2==3.24.0 for CUDA 11 +# NOTE: Ensure the 'snac' library is installed according to its specific instructions. +# NOTE: Starting numpy below 2.0 for initial safety, pip's resolver might adjust this. \ No newline at end of file diff --git a/settings.yaml b/settings.yaml new file mode 100644 index 0000000..9fd591d --- /dev/null +++ b/settings.yaml @@ -0,0 +1,96 @@ +# settings.yaml + +# ------------------------------- +# General Application Settings +# ------------------------------- +general: + log_level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL + interaction_mode: "push_to_talk" # "push_to_talk", "hotword", "both" + post_response_delay_sec: 0.5 + +# ------------------------------- +# Audio Input/Output +# ------------------------------- +audio: + input_device: null + output_device: null + # --- Voice Activity Detection (VAD) --- + vad: + enabled: true # Set to true to enable VAD (EXPERIMENTAL) + sample_rate: 16000 # Should match STT and Hotword expected rate + frame_duration_ms: 30 + aggressiveness: 2 + silence_duration_ms: 1200 + min_record_duration_ms: 500 + +# ------------------------------- +# Hotword Detection (OpenWakeWord) +# ------------------------------- +hotword: + enabled: false # Set to true to enable hotword detection (EXPERIMENTAL) - This is fully broken for now, keep it false. + # List of wake word models to listen for. + # Find available models in the openwakeword documentation or train your own. + # Example pre-trained models: "alexa", "hey_mycroft", "hey_jarvis", "timer" + models: ["hey_jarvis"] + # Path for custom models (optional): + # custom_model_paths: ["path/to/my_custom_model.onnx"] + inference_framework: "onnx" # Usually 'onnx', could be 'tflite' if supported/used + threshold: 0.7 # Confidence threshold for detection (0.0 to 1.0) + trigger_level: 1 # How many positive frames needed to trigger (usually 1) + chunk_size_ms: 1280 # How much audio (ms) to feed the model at once (check owm recommendations) + +# ------------------------------- +# STT (Speech-to-Text - Faster Whisper) +# ------------------------------- +stt: + model_size: "small.en" # Whisper model size (tiny.en, base.en, small.en, medium.en, large-v2, large-v3) + # Or path to a converted CTranslate2 model directory + device: "cuda" # "cuda" or "cpu" + compute_type: "float16" # "float16", "int8_float16", "int8", "float32" (check hardware/model support) + # --- Transcription Options --- + language: null # Language code (e.g., "en", "es") or null for auto-detect + beam_size: 5 + # vad_filter: false # Use faster-whisper's internal VAD? (Keeping our external VAD for now) + # vad_parameters: # Settings if vad_filter=true (e.g., min_silence_duration_ms: 500) + # min_silence_duration_ms: 1000 + +# ------------------------------- +# LLM (Language Model - LM Studio) +# ------------------------------- +llm: + base_url: "http://127.0.0.1:1234/v1" + request_timeout_sec: 60.0 + max_retries: 3 + # --- Chat Completion --- + chat: + endpoint: "/chat/completions" + model: "gemma-3-12b-it" + system_prompt: "You are a helpful and concise voice assistant named Morpheus." + max_tokens: 300 + temperature: 0.7 + top_p: 0.9 + repetition_penalty: 1.1 + +# ------------------------------- +# TTS (Text-to-Speech - LM Studio/SNAC) +# ------------------------------- +tts: + # --- API Settings --- + endpoint: "/completions" + model: "orpheus-3b-ft.gguf@q2_k" + # --- Synthesis Parameters --- + default_voice: "tara" + max_tokens: 8192 + temperature: 0.6 + top_p: 0.9 + repetition_penalty: 1.0 + speed: 1.0 + # --- Audio Output --- + sample_rate: 24000 + normalize_volume: false + # --- Text Segmentation --- + segmentation: + max_words_per_segment: 50 + # --- Output Files --- + output_dir: "outputs" + clear_output_on_start: true \ No newline at end of file diff --git a/settings.yml b/settings.yml deleted file mode 100644 index 3b30000..0000000 --- a/settings.yml +++ /dev/null @@ -1,88 +0,0 @@ -# ------------------------------- -# Configuration for Whisper STT -# ------------------------------- -whisper: - model: "small.en" # Name of the Whisper model to use for speech-to-text. - sample_rate: 16000 # Audio sample rate (in Hz) for recording and transcription. - -# ------------------------------- -# Configuration for LM Studio (Chat & TTS) -# ------------------------------- -lm: - api_url: "http://127.0.0.1:1234/v1" # Base URL for the LM Studio inference server. - - chat: - endpoint: "/chat/completions" # API endpoint for chat-based text generation. - model: "gemma-3-12b-it" # Model identifier for chat generation. - system_prompt: "You are a helpful assistant." # System prompt to set the context. - max_tokens: 256 # Increased for more coherent responses. - temperature: 0.7 # Sampling temperature; controls randomness. - top_p: 0.9 # Top-p (nucleus sampling) value. - repetition_penalty: 1.1 # Penalty factor to reduce repetitive outputs. - max_response_time: 10.0 # Increased timeout (in seconds) for slower responses. - - tts: - endpoint: "/completions" # API endpoint for text-to-speech synthesis. - model: "orpheus-3b-ft.gguf@q2_k" # Model identifier for TTS synthesis. - default_voice: "tara" # Default voice for TTS output. - max_tokens: 4096 # Optimal for Orpheus. - temperature: 0.6 # Sampling temperature for TTS. - top_p: 0.9 # Top-p sampling value for TTS generation. - repetition_penalty: 1.0 # Penalty to prevent repetitive TTS output. - speed: 1.0 # More natural speed. - max_segment_duration: 20 # Increased maximum seconds per TTS segment. - -# ------------------------------- -# TTS Audio Output Configuration -# ------------------------------- -tts: - sample_rate: 24000 # Sample rate (in Hz) for the generated TTS audio. - -# ------------------------------- -# Audio Device Configuration -# ------------------------------- -audio: - input_device: null # Specify input device ID, or leave null to use the system default. - output_device: null # Specify output device ID, or leave null to use the system default. - hotword_sample_rate: 16000 # Sample rate for hotword detection. - -# ------------------------------- -# Voice Activity Detection (VAD) Configuration -# ------------------------------- -vad: - mode: 2 # VAD aggressiveness (0 is least, 3 is most aggressive). - frame_duration_ms: 30 # Duration of each audio frame (in ms) for VAD analysis. - silence_threshold_ms: 1000 # Duration (in ms) of consecutive silence to stop recording. - min_record_time_ms: 2000 # Minimum recording duration (in ms). - -# ------------------------------- -# Hotword Detection Configuration -# ------------------------------- -hotword: - enabled: true # Whether hotword detection is enabled. - phrase: "Hey Cassie" # Hotword phrase (case insensitive). - sensitivity: 0.7 # Sensitivity (0-1); higher is more strict. - timeout_sec: 5 # Time (in seconds) to listen for hotword before timeout. - retries: 3 # Maximum attempts to detect the hotword. - -# ------------------------------- -# Segmentation Configuration for TTS -# ------------------------------- -segmentation: - max_words: 60 # Maximum number of words per segment when splitting long TTS responses. - -# ------------------------------- -# Speech Quality Configuration -# ------------------------------- -speech: - normalize_audio: false # Whether to normalize audio volume. - default_pitch: 0 # Pitch adjustment (-20 to +20). - min_speech_confidence: 0.5 # Minimum confidence for accepting speech. - max_retries: 3 # Maximum retries for API calls. - -# ------------------------------- -# Interaction Configuration -# ------------------------------- -interaction: - mode: "both" # Options: "push_to_talk", "hotword", or "both" to enable both simultaneously. - post_audio_delay: 0.5 # Delay (in seconds) after audio playback before next activation.