Initial commit: Cosmo voice assistant
Полностью локальный голосовой ассистент на Python. Стек: - Wake word: openWakeWord (onnxruntime) - STT: RealtimeSTT + faster-whisper + Silero VAD (CUDA) - LLM-агент: smolagents ToolCallingAgent + Ollama qwen2.5:7b - TTS: Silero V4 (torch.hub) + sounddevice - Shell: Git Bash (Windows) / bash (macOS) Поддерживает Windows и macOS. Агент с памятью и tool calling — находит программы самостоятельно, запоминает пути, выполняет произвольные shell-команды. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
139
cosmo/wake_word.py
Normal file
139
cosmo/wake_word.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Wake word detector для Cosmo.
|
||||
Слушает микрофон непрерывно, детектирует слово "cosmo" через openwakeword.
|
||||
"""
|
||||
|
||||
import os
|
||||
import glob
|
||||
import threading
|
||||
import queue
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
from openwakeword.model import Model
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class WakeWordDetector:
|
||||
def __init__(self, config: dict, on_detected_callback):
|
||||
"""
|
||||
config: секция audio + assistant из config.yaml
|
||||
on_detected_callback: вызывается без аргументов при детекте wake word
|
||||
"""
|
||||
self.sample_rate = config["audio"].get("sample_rate", 16000)
|
||||
self.chunk_duration = config["audio"].get("chunk_duration", 0.08)
|
||||
self.chunk_size = int(self.sample_rate * self.chunk_duration)
|
||||
self.wake_word = config["assistant"]["wake_word"]
|
||||
self.on_detected = on_detected_callback
|
||||
|
||||
self._audio_queue = queue.Queue()
|
||||
self._running = False
|
||||
self._paused = False
|
||||
self._thread = None
|
||||
|
||||
# Порог уверенности для срабатывания (0.0 – 1.0)
|
||||
self.threshold = 0.5
|
||||
|
||||
logger.info("Загружаю wake word модель openwakeword...")
|
||||
|
||||
# Ищем кастомную модель в папке models/
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
custom_models = glob.glob(os.path.join(project_root, "models", "*.onnx"))
|
||||
|
||||
if custom_models:
|
||||
model_path = custom_models[0]
|
||||
model_name = os.path.basename(model_path)
|
||||
try:
|
||||
self.model = Model(
|
||||
wakeword_models=[model_path],
|
||||
inference_framework="onnx",
|
||||
)
|
||||
logger.info(f"Кастомная wake word модель загружена: {model_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось загрузить кастомную модель {model_name}: {e}")
|
||||
raise
|
||||
else:
|
||||
# Fallback на встроенную hey_jarvis
|
||||
try:
|
||||
self.model = Model(
|
||||
wakeword_models=["hey_jarvis"],
|
||||
inference_framework="onnx",
|
||||
)
|
||||
logger.info("Wake word модель 'hey_jarvis' загружена (onnx)")
|
||||
logger.warning(
|
||||
"Кастомная модель не найдена в папке models/. "
|
||||
"Активация по слову 'Hey Jarvis'. "
|
||||
"Запусти train_wakeword/train.sh чтобы обучить модель на 'Hey Cosmo'."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось загрузить wake word модель: {e}")
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Публичный интерфейс
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self):
|
||||
"""Запустить детектор в фоновом потоке."""
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info(f"Wake word детектор запущен. Жду слово '{self.wake_word}'...")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
|
||||
def pause(self):
|
||||
"""Приостановить детект (пока идёт запись команды)."""
|
||||
self._paused = True
|
||||
|
||||
def resume(self):
|
||||
"""Возобновить детект после записи команды."""
|
||||
# Очищаем очередь, чтобы не срабатывать на эхо
|
||||
while not self._audio_queue.empty():
|
||||
try:
|
||||
self._audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._paused = False
|
||||
logger.debug("Wake word детектор возобновлён")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Внутренняя логика
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _audio_callback(self, indata, frames, time_info, status):
|
||||
if status:
|
||||
logger.debug(f"sounddevice статус: {status}")
|
||||
if not self._paused:
|
||||
# Копируем данные, т.к. буфер перезаписывается
|
||||
self._audio_queue.put(indata[:, 0].copy())
|
||||
|
||||
def _run(self):
|
||||
with sd.InputStream(
|
||||
samplerate=self.sample_rate,
|
||||
channels=1,
|
||||
dtype="float32",
|
||||
blocksize=self.chunk_size,
|
||||
callback=self._audio_callback,
|
||||
):
|
||||
while self._running:
|
||||
try:
|
||||
chunk = self._audio_queue.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# openwakeword ожидает int16 PCM
|
||||
chunk_int16 = (chunk * 32767).astype(np.int16)
|
||||
prediction = self.model.predict(chunk_int16)
|
||||
|
||||
for model_name, score in prediction.items():
|
||||
if score >= self.threshold:
|
||||
logger.info(
|
||||
f"Wake word задетектирован! Модель={model_name}, "
|
||||
f"уверенность={score:.2f}"
|
||||
)
|
||||
self.pause()
|
||||
self.on_detected()
|
||||
break
|
||||
Reference in New Issue
Block a user