Version 2

Major rewrite to version 2.

Some features are broken.
This commit is contained in:
Nighthawk
2025-03-28 17:21:21 -04:00
parent d09c517c79
commit 5d19f9afc7
25 changed files with 2577 additions and 1069 deletions
-1
View File
@@ -1,2 +1 @@
*.pyc
mOrpheus.txt
-195
View File
@@ -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 Studios chat API to generate natural language responses based on transcribed input.
- **Text-to-Speech:**
Converts the generated text into speech via LM Studios 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 pushtotalk (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 Studios chat API.
- **TTS Synthesis:**
The response is cleaned to remove unwanted characters (e.g., emojis, newlines, markdown formatting) and then sent to LM Studios 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.
View File
-88
View File
@@ -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
+382
View File
@@ -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
-20
View File
@@ -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
+162
View File
@@ -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
-82
View File
@@ -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))
+277
View File
@@ -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
+210
View File
@@ -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()
-218
View File
@@ -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
+124
View File
@@ -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.")
-35
View File
@@ -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)
-34
View File
@@ -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
+108
View File
@@ -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())
-92
View File
@@ -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 "<custom_token_" not in token_string:
return None
last_token_start = token_string.rfind("<custom_token_")
if last_token_start == -1 or not token_string.endswith(">"):
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)
+245
View File
@@ -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
+274
View File
@@ -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 "<custom_token_" not in token_string: return None
last_token_start = token_string.rfind("<custom_token_")
if last_token_start == -1 or not token_string.endswith(">"): 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)
+298
View File
@@ -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()
-138
View File
@@ -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
-54
View File
@@ -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 ""
+355 -15
View File
@@ -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()
+46 -9
View File
@@ -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.
+96
View File
@@ -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
-88
View File
@@ -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.