Files
cosmo-voice-assistant/cosmo/wake_word.py
Daniil Klimov 110d9cde29 Mac M1 optimizations, fix train pipeline, add Hey Cosmo wake word model
- Fix install_mac.sh: use venv + Python 3.12 (3.14 incompatible with ML libs)
- Fix run_mac.sh: activate venv, add CPU thread optimization env vars
- Fix agent.py: remove f-string from SYSTEM_PROMPT template (NameError on import)
- Add missing deps: sounddevice, pydub, imageio-ffmpeg, omegaconf
- Optimize for M1: torch.inference_mode, set_num_threads, OMP/MKL tuning
- Switch to qwen2.5:3b for faster LLM responses on Mac
- Switch Whisper to medium model with auto compute (small+int8 had poor Russian)
- Add initial_prompt for better Russian transcription
- Add open_app tool for native macOS app launching
- Fix TTS: sanitize Latin text to Cyrillic for Silero compatibility
- Fix wake word echo: add cooldown after TTS, reset model state, raise threshold
- Make "Слушаю" TTS synchronous to avoid mic interference
- Fix train Dockerfile: remove tensorflow/onnx2tf (only ONNX needed), fix deps
- Fix train.sh: use wget for dataset download, add --shm-size=2g
- Add trained hey_cosmo.onnx wake word model
- Add TODO section to CLAUDE.md (ChatterBox TTS, Ollama Modelfile ideas)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:19:53 +03:00

146 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Wake word detector для Cosmo.
Слушает микрофон непрерывно, детектирует слово "cosmo" через openwakeword.
"""
import os
import glob
import time
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)
# 0.7 — баланс между надёжностью и защитой от ложных срабатываний/эха TTS
self.threshold = 0.7
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, cooldown: float = 1.5):
"""Возобновить детект после записи команды с защитой от эха."""
# Ждём пока эхо от TTS затухнет
time.sleep(cooldown)
# Очищаем очередь — там буферизованный звук TTS
while not self._audio_queue.empty():
try:
self._audio_queue.get_nowait()
except queue.Empty:
break
# Сбрасываем внутреннее состояние модели (накопленные скоры)
self.model.reset()
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