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>
This commit is contained in:
2026-04-11 11:19:53 +03:00
parent 6010816f1d
commit 110d9cde29
15 changed files with 183 additions and 94 deletions

View File

@@ -19,7 +19,7 @@ else:
from cosmo.tools import ALL_TOOLS, set_memory
_PLATFORM_NOTE = "Windows. Используй Git Bash, 'start' для запуска приложений."
SYSTEM_PROMPT = f"""Ты — Cosmo, умный голосовой ассистент. Платформа: {_PLATFORM_NOTE}
SYSTEM_PROMPT = """Ты — Cosmo, умный голосовой ассистент. Платформа: """ + _PLATFORM_NOTE + """
Правила:
1. Используй инструменты для выполнения задач — не выдумывай результаты

View File

@@ -71,7 +71,8 @@ class Cosmo:
self._command_event.set()
def _process_command(self):
self.tts.say_async("Слушаю")
# Синхронно — сначала говорим, потом слушаем (иначе TTS мешает записи)
self.tts.say("Слушаю")
# Partial results — печатаем в лог что слышим в реальном времени
def on_partial(text):

View File

@@ -61,9 +61,10 @@ def find_program(name: str) -> str:
"""
Найти программу или приложение на macOS по имени.
Ищет в PATH, /Applications, ~/Applications и через Spotlight (mdfind).
Возвращает путь. Чтобы запустить — используй open_app.
Args:
name: имя программы, например 'webstorm', 'chrome', 'cursor'
name: имя программы, например 'webstorm', 'chrome', 'cursor', 'safari'
"""
stem = name.strip()
logger.info(f"[find_program] {stem}")
@@ -92,6 +93,27 @@ def find_program(name: str) -> str:
return f"Программа '{name}' не найдена."
@tool
def open_app(name: str) -> str:
"""
Запустить приложение на macOS по имени или полному пути.
Примеры: open_app("Safari"), open_app("/Applications/Safari.app"), open_app("Telegram").
Args:
name: имя приложения или полный путь к .app
"""
logger.info(f"[open_app] {name}")
# Если передан полный путь к .app
if name.endswith(".app") and os.path.exists(name):
result = run_shell(f'open "{name}"')
else:
# Пробуем по имени через open -a
result = run_shell(f'open -a "{name}"')
if result.startswith("[ошибка") or "returncode=1" in result:
return f"Не удалось открыть '{name}'. Попробуй find_program чтобы найти точное имя."
return f"Приложение '{name}' запущено."
@tool
def open_browser(url: str, search: bool = False) -> str:
"""
@@ -197,6 +219,7 @@ def memory_list(prefix: str = "") -> str:
ALL_TOOLS = [
run_shell,
find_program,
open_app,
open_browser,
read_file,
write_file,

View File

@@ -20,11 +20,13 @@ class Transcriber:
"language": whisper_cfg["language"],
"device": whisper_cfg["device"],
"compute_type": whisper_cfg["compute_type"],
# Подсказка для Whisper — улучшает распознавание русского
"initial_prompt": whisper_cfg.get("initial_prompt", ""),
# Silero VAD параметры
"silero_sensitivity": 0.4,
"webrtc_sensitivity": 3,
"post_speech_silence_duration": audio_cfg["silence_duration"],
"min_length_of_recording": 0.3,
"min_length_of_recording": 0.5,
"min_gap_between_recordings": 0.01,
# Отключаем wake word в RealtimeSTT — используем свой
"wakeword_backend": "none",

View File

@@ -32,6 +32,10 @@ class TTS:
self.enabled = False
return
# Оптимизация CPU-инференса на Apple Silicon
num_threads = config.get("performance", {}).get("num_threads", 4)
torch.set_num_threads(num_threads)
self._load_model()
def _load_model(self):
@@ -52,16 +56,33 @@ class TTS:
logger.warning("TTS отключён")
self.enabled = False
@staticmethod
def _sanitize_text(text: str) -> str:
"""Заменяет латиницу на читаемый русский для TTS."""
import re
# Транслитерация частых англ. слов которые Silero не прочитает
text = re.sub(r'[Ss]afari', 'Сафари', text)
text = re.sub(r'[Cc]hrome', 'Хром', text)
text = re.sub(r'[Tt]elegram', 'Телеграм', text)
text = re.sub(r'[Ww]eb[Ss]torm', 'ВебШторм', text)
text = re.sub(r'[Vv][Ss]\s?[Cc]ode', 'ВиЭс Код', text)
# Оставшиеся латинские слова — убираем, чтобы Silero не зависал
text = re.sub(r'[A-Za-z]+', '', text)
# Убираем лишние пробелы
text = re.sub(r'\s+', ' ', text).strip()
return text if text else "Готово"
def say(self, text: str):
"""Произнести текст синхронно."""
if not self.enabled or self._model is None:
logger.info(f"[TTS]: {text}")
return
text = self._sanitize_text(text)
logger.debug(f"TTS: '{text}'")
with self._lock:
try:
with torch.no_grad():
with torch.inference_mode():
audio = self._model.apply_tts(
text=text,
speaker=self.speaker,

View File

@@ -5,6 +5,7 @@ Wake word detector для Cosmo.
import os
import glob
import time
import threading
import queue
import numpy as np
@@ -31,7 +32,8 @@ class WakeWordDetector:
self._thread = None
# Порог уверенности для срабатывания (0.0 1.0)
self.threshold = 0.5
# 0.7 — баланс между надёжностью и защитой от ложных срабатываний/эха TTS
self.threshold = 0.7
logger.info("Загружаю wake word модель openwakeword...")
@@ -88,14 +90,18 @@ class WakeWordDetector:
"""Приостановить детект (пока идёт запись команды)."""
self._paused = True
def resume(self):
"""Возобновить детект после записи команды."""
# Очищаем очередь, чтобы не срабатывать на эхо
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 детектор возобновлён")