diff --git a/CLAUDE.md b/CLAUDE.md index 7b9ec4c..9455cfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,11 @@ def my_tool(param: str) -> str: Добавь в список `ALL_TOOLS` в конце файла — агент автоматически получит доступ. +## TODO / Идеи + +- [ ] **ChatterBox TTS вместо Silero** — более живой голос с эмоциями и клонированием голоса (10-30 сек сэмпла). Источник: [vndee/local-talking-llm](https://github.com/vndee/local-talking-llm). Параметр `exaggeration` (0.0–1.0) управляет экспрессивностью. Заменить `cosmo/tts.py`, интерфейс `say()`/`say_async()` не меняется. Проверить латентность на M1 CPU. +- [ ] **Ollama Modelfile** — запечь системный промпт и параметры (короткие ответы, русский язык) прямо в модель через Modelfile, вместо передачи в каждом запросе. + ## Разработка Логи пишутся в `logs/cosmo.log`. Уровень логирования меняется в конфиге (`logging.level: DEBUG`). diff --git a/config/config_mac.yaml b/config/config_mac.yaml index 6422a63..b1a232a 100644 --- a/config/config_mac.yaml +++ b/config/config_mac.yaml @@ -7,18 +7,22 @@ audio: silence_duration: 1.0 whisper: - model_size: "small" # На Mac без GPU — small быстрее чем distil-large + model_size: "medium" # medium — лучшее качество русского на CPU (small слишком много ошибок) device: "cpu" # Mac Intel/Apple Silicon — CPU (MPS пока не стабилен в faster-whisper) - compute_type: "int8" # int8 быстрее на CPU + compute_type: "auto" # auto вместо int8 — int8 слишком сильно режет качество русского language: "ru" + initial_prompt: "Cosmo, открой браузер, найди программу, запусти приложение." # подсказка для русского контекста ollama: base_url: "http://localhost:11434" - model: "qwen2.5:7b" + model: "qwen2.5:3b" # 3b быстрее на M1 (~2x), достаточно для голосовых команд temperature: 0.2 max_tokens: 1024 max_agent_steps: 10 +performance: + num_threads: 4 # CPU потоки для torch (TTS/Whisper) + tts: enabled: true silero_speaker: "eugene" # xenia (женский) baya aidar eugene kseniya diff --git a/cosmo/agent.py b/cosmo/agent.py index de071df..6dcc423 100644 --- a/cosmo/agent.py +++ b/cosmo/agent.py @@ -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. Используй инструменты для выполнения задач — не выдумывай результаты diff --git a/cosmo/main.py b/cosmo/main.py index ad7695a..da56edf 100644 --- a/cosmo/main.py +++ b/cosmo/main.py @@ -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): diff --git a/cosmo/tools_mac.py b/cosmo/tools_mac.py index 5694d06..f38505c 100644 --- a/cosmo/tools_mac.py +++ b/cosmo/tools_mac.py @@ -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, diff --git a/cosmo/transcriber.py b/cosmo/transcriber.py index 09bf588..0ae6087 100644 --- a/cosmo/transcriber.py +++ b/cosmo/transcriber.py @@ -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", diff --git a/cosmo/tts.py b/cosmo/tts.py index 1742155..0a19efa 100644 --- a/cosmo/tts.py +++ b/cosmo/tts.py @@ -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, diff --git a/cosmo/wake_word.py b/cosmo/wake_word.py index 86aa1f3..c5e1fd3 100644 --- a/cosmo/wake_word.py +++ b/cosmo/wake_word.py @@ -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 детектор возобновлён") diff --git a/install_mac.sh b/install_mac.sh index 897bedd..002889c 100644 --- a/install_mac.sh +++ b/install_mac.sh @@ -6,14 +6,23 @@ echo " Установка Cosmo на macOS" echo "============================================" # --- Python --- -if ! command -v python3 &>/dev/null; then +# Предпочитаем Python 3.12 (лучшая совместимость с ML-пакетами) +PYTHON_BIN="" +for py in python3.12 python3.11 python3.13 python3; do + if command -v "$py" &>/dev/null; then + PYTHON_BIN="$py" + break + fi +done + +if [ -z "$PYTHON_BIN" ]; then echo "ОШИБКА: Python3 не найден." - echo "Установи через Homebrew: brew install python@3.11" + echo "Установи через Homebrew: brew install python@3.12" exit 1 fi -PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") -echo "Python: $PYTHON_VERSION" +PYTHON_VERSION=$($PYTHON_BIN -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") +echo "Python: $PYTHON_VERSION ($PYTHON_BIN)" # --- Homebrew зависимости --- if command -v brew &>/dev/null; then @@ -24,19 +33,22 @@ else echo "Если будут ошибки с аудио — установи: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" fi -echo "[2/5] Обновляю pip..." -python3 -m pip install --upgrade pip +echo "[2/5] Создаю виртуальное окружение..." +rm -rf venv +$PYTHON_BIN -m venv venv +source venv/bin/activate -echo "[3/5] Устанавливаю зависимости..." -python3 -m pip install -r requirements.txt +echo "[3/5] Обновляю pip и устанавливаю зависимости..." +pip install --upgrade pip +pip install -r requirements.txt echo "[4/5] Устанавливаю faster-whisper..." # На Mac (Apple Silicon) используем CPU compute type -python3 -m pip install faster-whisper +pip install faster-whisper echo "[5/5] Устанавливаю openwakeword..." -python3 -m pip install openwakeword -python3 -c "import openwakeword; openwakeword.utils.download_models()" 2>/dev/null || true +pip install openwakeword +python -c "import openwakeword; openwakeword.utils.download_models()" 2>/dev/null || true echo "" echo "============================================" diff --git a/models/hey_cosmo.onnx b/models/hey_cosmo.onnx new file mode 100644 index 0000000..bc00364 Binary files /dev/null and b/models/hey_cosmo.onnx differ diff --git a/requirements.txt b/requirements.txt index f4cedbb..da869e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,11 @@ ollama==0.4.4 # официальный Python клиент Ollama pyyaml==6.0.2 loguru==0.7.2 +# Аудио +sounddevice>=0.5.0 +pydub>=0.25.1 +imageio-ffmpeg>=0.6.0 + # Инструменты агента psutil==6.0.0 pyautogui==0.9.54 diff --git a/run_mac.sh b/run_mac.sh index 8005e66..da4fb91 100644 --- a/run_mac.sh +++ b/run_mac.sh @@ -10,5 +10,18 @@ if ! curl -s http://localhost:11434 &>/dev/null; then sleep 2 fi +# Оптимизация CPU-инференса на Apple Silicon +export OMP_NUM_THREADS=4 +export MKL_NUM_THREADS=4 +export PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 + +# Активируем venv +if [ -d "venv" ]; then + source venv/bin/activate +else + echo "venv не найден. Сначала запусти: bash install_mac.sh" + exit 1 +fi + # Запускаем с Mac-конфигом -COSMO_PLATFORM=mac python3 cosmo/main.py --config config/config_mac.yaml "$@" +COSMO_PLATFORM=mac python cosmo/main.py --config config/config_mac.yaml "$@" diff --git a/train_wakeword/Dockerfile b/train_wakeword/Dockerfile index c50ac4a..0908a2f 100644 --- a/train_wakeword/Dockerfile +++ b/train_wakeword/Dockerfile @@ -1,88 +1,86 @@ -# Dockerfile для обучения wake word модели openWakeWord -# Python 3.11 + torch 2.5 (последний совместимый с py3.11) + рабочие зависимости 2026 +# Dockerfile для обуч��ния wake word модели openWakeWord +# Python 3.11 + torch (CPU) — без tensorflow (нам нужен только ONNX, не TFLite) FROM python:3.11-slim WORKDIR /app -# Системные зависимости (включая build-essential для webrtcvad) -RUN apt-get update && apt-get install -y \ +# Системные зависимости +RUN apt-get update && apt-get install -y --no-install-recommends \ git wget curl ffmpeg libsndfile1 \ - build-essential python3-dev \ + build-essential python3-dev cmake \ && rm -rf /var/lib/apt/lists/* -# Клонируем openWakeWord и piper-sample-generator -RUN git clone https://github.com/dscripka/openWakeWord /openWakeWord -RUN git clone https://github.com/rhasspy/piper-sample-generator /piper-sample-generator - -# Torch 2.5.0 — последний для Python 3.11, CPU версия (обучение не требует GPU) +# --- Слой 1: PyTorch (самый тяжёлый, кэшируется) --- RUN pip install --no-cache-dir \ torch==2.5.0 \ torchaudio==2.5.0 \ --index-url https://download.pytorch.org/whl/cpu -# Зависимости обучения с совместимыми версиями +# --- Слой 2: ML-зависимости (без tensorflow!) --- RUN pip install --no-cache-dir \ mutagen==1.47.0 \ torchinfo==1.8.0 \ torchmetrics==1.2.0 \ speechbrain==1.0.3 \ - audiomentations==0.43.1 \ - torch-audiomentations==0.12.0 \ pronouncing==0.2.0 \ - "datasets==2.20.0" \ - "pyarrow==14.0.2" \ - "fsspec==2023.12.2" \ acoustics==0.2.6 \ + pyyaml "scipy<1.15" scikit-learn tqdm + +# --- Слой 3: Аудио-аугментация --- +RUN pip install --no-cache-dir \ + audiomentations==0.43.1 \ + torch-audiomentations==0.12.0 + +# --- Слой 4: Датасеты и ONNX --- +RUN pip install --no-cache-dir \ + "datasets>=2.20.0" \ + "pyarrow>=15.0.0" \ webrtcvad \ onnx \ - onnxruntime \ - onnx2tf \ - pyyaml scipy scikit-learn tqdm - -# TFLite конвертация через onnx2tf (замена мёртвого onnx_tf) -# Патчим train.py чтобы использовал onnx2tf вместо onnx_tf -RUN pip install --no-cache-dir \ - tensorflow-cpu==2.21.0 \ - tensorflow_probability==0.24.0 + onnxruntime +# --- Сл��й 5: openWakeWord --- +RUN git clone https://github.com/dscripka/openWakeWord /openWakeWord RUN pip install --no-cache-dir -e /openWakeWord -# Патч: заменяем onnx_tf на onnx2tf в train.py -RUN python - <<'EOF' -import re, pathlib +# Ресурсные модели для feature extraction (melspectrogram + embedding) +RUN mkdir -p /openWakeWord/openwakeword/resources/models && \ + wget -q -O /openWakeWord/openwakeword/resources/models/melspectrogram.onnx \ + "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/melspectrogram.onnx" && \ + wget -q -O /openWakeWord/openwakeword/resources/models/embedding_model.onnx \ + "https://github.com/dscripka/openWakeWord/releases/download/v0.5.1/embedding_model.onnx" + +# Патч train.py: убираем зависимость от onnx_tf/tensorflow (нам нужен только ONNX) +RUN python - <<'PATCH' +import pathlib + train_py = pathlib.Path("/openWakeWord/openwakeword/train.py") text = train_py.read_text() -# Заменяем импорт onnx_tf -text = text.replace( - "import onnx_tf", - "import onnx2tf as onnx_tf_compat" -) -text = text.replace( - "from onnx_tf.backend import prepare", - "# onnx_tf replaced by onnx2tf" -) -# Заменяем вызов convert_onnx_to_tflite если он есть -text = re.sub( - r"onnx_tf\.backend\.prepare\(.*?\)", - "None # onnx2tf handles tflite conversion differently", - text, flags=re.DOTALL -) -train_py.write_text(text) -print("train.py patched OK") -EOF -# Устанавливаем piper-sample-generator +# Заменяем всю функцию convert_onnx_to_tflite на заглушку +old_func = text[text.find("def convert_onnx_to_tflite("):] +old_func = old_func[:old_func.find("\nif __name__")] +new_func = '''def convert_onnx_to_tflite(onnx_model_path, output_path): + """Skipped — ONNX-only mode, TFLite not needed.""" + return None +''' +text = text.replace(old_func, new_func) + +train_py.write_text(text) +print("train.py patched: convert_onnx_to_tflite replaced with stub") +PATCH + +# --- Слой 6: piper-sample-generator v2.0.0 (совместим с openWakeWord train.py) --- +RUN git clone --branch v2.0.0 https://github.com/rhasspy/piper-sample-generator /piper-sample-generator +RUN pip install --no-cache-dir piper-phonemize || true RUN pip install --no-cache-dir -e /piper-sample-generator 2>/dev/null || \ pip install --no-cache-dir piper-tts -# Скачиваем TTS модель LibriTTS-R medium (~66 MB) для генерации примеров +# TTS модель (.pt checkpoint) для генерации примеров RUN mkdir -p /piper-sample-generator/models && \ wget -q --show-progress \ - -O /piper-sample-generator/models/en_US-libritts_r-medium.onnx \ - "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx" && \ - wget -q \ - -O /piper-sample-generator/models/en_US-libritts_r-medium.onnx.json \ - "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/libritts_r/medium/en_US-libritts_r-medium.onnx.json" + -O /piper-sample-generator/models/en_US-libritts_r-medium.pt \ + "https://github.com/rhasspy/piper-sample-generator/releases/download/v2.0.0/en_US-libritts_r-medium.pt" RUN mkdir -p /data /output /samples diff --git a/train_wakeword/cosmo_config.yaml b/train_wakeword/cosmo_config.yaml index d0becee..0779ebe 100644 --- a/train_wakeword/cosmo_config.yaml +++ b/train_wakeword/cosmo_config.yaml @@ -40,6 +40,11 @@ batch_n_per_class: "adversarial_negative": 50 "positive": 50 +# Пути для аугментации (пустые — аугментация без RIR и фонового шума) +rir_paths: [] +background_paths: [] +background_paths_duplication_rate: [] + # Архитектура модели model_type: "dnn" layer_size: 32 diff --git a/train_wakeword/train.sh b/train_wakeword/train.sh index 36ca085..cf9fe9f 100644 --- a/train_wakeword/train.sh +++ b/train_wakeword/train.sh @@ -51,24 +51,17 @@ NEGATIVE_FEATURES="$DATA_DIR/openwakeword_features_ACAV100M_2000_hrs_16bit.npy" VALIDATION_FEATURES="$DATA_DIR/validation_set_features.npy" if [ ! -f "$NEGATIVE_FEATURES" ]; then - echo "[2/4] Скачиваю негативный датасет (~20 GB, один раз)..." + echo "[2/4] Скачиваю негативный датасет (~17 GB + ~500 MB, один раз)..." echo " Это займёт время в зависимости от скорости интернета." - docker run --rm \ - -v "$DATA_DIR:/data" \ - cosmo-wakeword-trainer \ - python -c " -from datasets import load_dataset -import numpy as np, os -print('Скачиваю ACAV100M features...') -ds = load_dataset('davidscripka/openwakeword_features', 'ACAV100M_2000_hrs_16bit', split='train') -arr = np.array(ds['features']) -np.save('/data/openwakeword_features_ACAV100M_2000_hrs_16bit.npy', arr) -print('Скачиваю validation features...') -ds_val = load_dataset('davidscripka/openwakeword_features', 'validation_set', split='train') -arr_val = np.array(ds_val['features']) -np.save('/data/validation_set_features.npy', arr_val) -print('Датасет скачан.') -" + echo "" + echo " Скачиваю ACAV100M features (~17 GB)..." + wget -q --show-progress \ + -O "$NEGATIVE_FEATURES" \ + "https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/openwakeword_features_ACAV100M_2000_hrs_16bit.npy" + echo " Скачиваю validation features (~500 MB)..." + wget -q --show-progress \ + -O "$VALIDATION_FEATURES" \ + "https://huggingface.co/datasets/davidscripka/openwakeword_features/resolve/main/validation_set_features.npy" echo " Датасет готов." else echo "[2/4] Негативный датасет уже скачан. Пропускаю." @@ -86,6 +79,7 @@ if [ -d "$POSITIVE_DIR" ] && [ -n "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; t fi docker run --rm \ + --shm-size=2g \ -v "$SCRIPT_DIR/cosmo_config.yaml:/app/cosmo_config.yaml" \ -v "$DATA_DIR:/data" \ -v "$MODELS_DIR:/output" \