commit 6010816f1d406961a927ce14a4f7aae45a5d1d64 Author: d.klimov Date: Fri Apr 10 15:58:12 2026 +0300 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fe7d01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ + +# Виртуальные окружения +venv/ +env/ +.venv/ + +# Логи +logs/ +*.log + +# Данные и модели (большие файлы) +data/ +train_wakeword/docker_data/ +train_wakeword/samples/ + +# Память ассистента (персональные данные) +data/memory.json + +# Wake word модели (скачиваются/обучаются локально) +models/*.onnx +models/*.tflite + +# Torch кэш +.cache/ + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Claude Code +.claude/ +realtimesst.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7b9ec4c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,208 @@ +# Cosmo — локальный голосовой ассистент + +Полностью локальный голосовой ассистент на Python. Не использует облачные API, всё работает на твоём железе. + +## Архитектура + +``` +Микрофон → Wake Word → STT → LLM-агент → TTS → Динамики + ↓ ↓ ↓ + openWakeWord Whisper smolagents + (hey_jarvis) (cuda) + Ollama + ↓ + Инструменты: + run_shell, find_program, + open_browser, read/write_file, + memory_get/set +``` + +**Поток работы:** +1. `wake_word.py` — непрерывно слушает микрофон, детектирует слово-триггер +2. `transcriber.py` — записывает команду, транскрибирует через Whisper +3. `agent.py` — отправляет текст в Ollama, LLM вызывает инструменты в цикле пока не решит задачу +4. `tts.py` — озвучивает ответ через Silero + +## Структура файлов + +``` +cosmo/ +├── main.py — точка входа, класс Cosmo +├── wake_word.py — детект wake word (openWakeWord + onnxruntime) +├── transcriber.py — STT (RealtimeSTT + faster-whisper + Silero VAD) +├── agent.py — LLM-агент (smolagents ToolCallingAgent + Ollama) +├── tools.py — инструменты агента для Windows (Git Bash) +├── tools_mac.py — инструменты агента для macOS (нативный bash) +├── memory.py — персистентная память (data/memory.json) +└── tts.py — TTS (Silero V4 через torch.hub + sounddevice) + +config/ +├── config.yaml — настройки для Windows +└── config_mac.yaml — настройки для macOS (CPU, int8) + +train_wakeword/ +├── record_samples.py — запись голосовых примеров +├── cosmo_config.yaml — конфиг обучения wake word +├── Dockerfile — среда Python 3.11 для обучения +├── entrypoint.sh — шаги обучения внутри Docker +└── train.sh — главный скрипт обучения + +data/ +└── memory.json — долгосрочная память агента (создаётся автоматически) + +models/ +└── *.onnx — кастомные wake word модели (после обучения) +``` + +## Железо (Windows машина) + +- CPU: Intel i5-13400F (10 ядер / 16 потоков) +- RAM: 32 ГБ DDR5 6000 +- GPU: RTX 4060 8 ГБ VRAM +- OS: Windows 11, Python 3.13 + +## Стек технологий + +| Компонент | Технология | Версия | +|---|---|---| +| Wake word | openWakeWord + onnxruntime | 0.6.0 | +| STT | RealtimeSTT + faster-whisper | 0.3.104 / 1.1.1 | +| VAD | Silero VAD (внутри RealtimeSTT) | — | +| LLM | Ollama + qwen2.5:7b | ollama 0.6.1 | +| Agent | smolagents ToolCallingAgent | 1.11.0 | +| TTS | Silero V4 (torch.hub) + sounddevice | — | +| Shell | Git Bash (Windows) / bash (macOS) | — | + +## Запуск + +### Windows +```bash +bash install.sh # первый раз +bash run.sh +``` + +### macOS +```bash +bash install_mac.sh # первый раз +bash run_mac.sh +``` + +При первом запуске автоматически скачаются: +- Whisper модель (~1.5 ГБ на Windows / ~150 МБ на Mac) +- Silero TTS модель (~38 МБ) + +## Активация + +Сейчас: говори **"Hey Jarvis"** — это fallback пока нет кастомной модели. + +После обучения кастомной модели: говори **"Hey Cosmo"**. + +После активации говори команду на русском: *"открой браузер"*, *"найди в гугле погоду"*, *"запусти WebStorm"*. + +## Конфиг (config/config.yaml) + +```yaml +whisper: + model_size: "distil-large-v3" # Windows GPU + # model_size: "small" # Mac CPU + device: "cuda" # "cpu" для Mac + compute_type: "float16" # "int8" для CPU + +ollama: + model: "qwen2.5:7b" # модель должна быть скачана: ollama pull qwen2.5:7b + max_agent_steps: 10 # макс. шагов агента на одну команду + +tts: + silero_speaker: "eugene" # голоса: xenia (ж), baya, aidar, eugene, kseniya +``` + +## Память агента + +Агент автоматически запоминает информацию в `data/memory.json`: + +```json +{ + "facts": { + "app.webstorm": "C:/Program Files/JetBrains/WebStorm.../webstorm64.exe", + "user.name": "Даниил" + }, + "history": [...] +} +``` + +**Ключи памяти:** +- `app.<название>` — пути к программам +- `user.<поле>` — данные о пользователе +- `pref.<что>` — предпочтения + +После первого поиска программы агент запомнит её путь и больше не будет искать. + +## Инструменты агента + +| Инструмент | Описание | +|---|---| +| `run_shell` | Выполнить bash команду | +| `find_program` | Найти программу (PATH, Program Files, реестр / Spotlight на Mac) | +| `open_browser` | Открыть URL или поиск Google | +| `read_file` | Прочитать файл | +| `write_file` | Записать файл | +| `memory_get` | Получить факт из памяти | +| `memory_set` | Сохранить факт в память | +| `memory_list` | Показать все факты | + +## Обучение кастомной wake word "Hey Cosmo" + +Требования: Docker Desktop, ~25 ГБ свободного места. + +```bash +# Опционально: запиши свой голос (30 примеров) +python train_wakeword/record_samples.py + +# Запусти обучение (~1 час) +bash train_wakeword/train.sh +``` + +Датасет (~20 ГБ) скачается в `train_wakeword/docker_data/` и сохранится для повторного использования. + +После обучения `.onnx` модель автоматически появится в `models/` и `wake_word.py` подхватит её. + +## Известные ограничения + +- Wake word только английский ("Hey Jarvis" / "Hey Cosmo") — openWakeWord не поддерживает русский TTS для обучения +- На Mac нет CUDA — Whisper работает на CPU, латентность выше (~2-3 сек вместо ~0.5 сек) +- smolagents требует модель с поддержкой tool calling — qwen2.5:7b, llama3.2, mistral v0.3+ + +## Добавление новых инструментов + +В `cosmo/tools.py` (Windows) или `cosmo/tools_mac.py` (Mac): + +```python +@tool +def my_tool(param: str) -> str: + """ + Описание что делает инструмент — LLM читает это. + + Args: + param: описание параметра + """ + # реализация + return "результат" +``` + +Добавь в список `ALL_TOOLS` в конце файла — агент автоматически получит доступ. + +## Разработка + +Логи пишутся в `logs/cosmo.log`. Уровень логирования меняется в конфиге (`logging.level: DEBUG`). + +Для тестирования агента без голоса: +```bash +python -c " +import yaml, sys +sys.path.insert(0, '.') +from cosmo.memory import Memory +from cosmo.agent import Agent +config = yaml.safe_load(open('config/config.yaml')) +agent = Agent(config, Memory()) +print(agent.run('открой браузер')) +" +``` diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..7068d6c --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,29 @@ +assistant: + name: Cosmo + wake_word: "cosmo" + +audio: + sample_rate: 16000 + silence_duration: 1.0 # секунд тишины = конец команды + +whisper: + model_size: "distil-large-v3" # быстрее large-v3, почти такая же точность + device: "cuda" + compute_type: "float16" + language: "ru" + +ollama: + base_url: "http://localhost:11434" + model: "qwen2.5:7b" + temperature: 0.2 + max_tokens: 1024 + max_agent_steps: 10 + +tts: + enabled: true + silero_speaker: "eugene" # xenia (женский) или baya, aidar, eugene, kseniya, random + sample_rate: 48000 + +logging: + level: "INFO" + file: "logs/cosmo.log" diff --git a/config/config_mac.yaml b/config/config_mac.yaml new file mode 100644 index 0000000..6422a63 --- /dev/null +++ b/config/config_mac.yaml @@ -0,0 +1,29 @@ +assistant: + name: Cosmo + wake_word: "cosmo" + +audio: + sample_rate: 16000 + silence_duration: 1.0 + +whisper: + model_size: "small" # На Mac без GPU — small быстрее чем distil-large + device: "cpu" # Mac Intel/Apple Silicon — CPU (MPS пока не стабилен в faster-whisper) + compute_type: "int8" # int8 быстрее на CPU + language: "ru" + +ollama: + base_url: "http://localhost:11434" + model: "qwen2.5:7b" + temperature: 0.2 + max_tokens: 1024 + max_agent_steps: 10 + +tts: + enabled: true + silero_speaker: "eugene" # xenia (женский) baya aidar eugene kseniya + sample_rate: 48000 + +logging: + level: "INFO" + file: "logs/cosmo.log" diff --git a/cosmo/__init__.py b/cosmo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cosmo/agent.py b/cosmo/agent.py new file mode 100644 index 0000000..de071df --- /dev/null +++ b/cosmo/agent.py @@ -0,0 +1,89 @@ +""" +Агент на базе smolagents + Ollama. +Использует ToolCallingAgent — LLM вызывает инструменты через JSON tool calling. +Продолжает работу пока задача не решена (до max_steps). +""" + +import os +import platform +from loguru import logger +from smolagents import ToolCallingAgent, LiteLLMModel + +from cosmo.memory import Memory + +# Выбираем инструменты под текущую платформу +if platform.system() == "Darwin" or os.environ.get("COSMO_PLATFORM") == "mac": + from cosmo.tools_mac import ALL_TOOLS, set_memory + _PLATFORM_NOTE = "macOS. Используй bash, 'open -a AppName' для запуска приложений, mdfind для поиска файлов." +else: + from cosmo.tools import ALL_TOOLS, set_memory + _PLATFORM_NOTE = "Windows. Используй Git Bash, 'start' для запуска приложений." + +SYSTEM_PROMPT = f"""Ты — Cosmo, умный голосовой ассистент. Платформа: {_PLATFORM_NOTE} + +Правила: +1. Используй инструменты для выполнения задач — не выдумывай результаты +2. Если первая попытка не сработала — пробуй другой подход, не сдавайся +3. Перед поиском программы — проверь память (memory_get), может путь уже известен +4. Если нашёл путь к программе — сохрани в память (memory_set) чтобы не искать повторно +5. Отвечай коротко на русском языке — пользователь слушает голосом, не читает + +Факты из памяти о пользователе и системе: +{memory_facts} +""" + + +class Agent: + def __init__(self, config: dict, memory: Memory): + self.memory = memory + self._cfg = config["ollama"] + + # Передаём память в инструменты + set_memory(memory) + + model_id = f"ollama/{self._cfg['model']}" + logger.info(f"Инициализирую smolagents с моделью {model_id}") + + self._model = LiteLLMModel( + model_id=model_id, + api_base=self._cfg["base_url"], + temperature=self._cfg.get("temperature", 0.2), + max_tokens=self._cfg.get("max_tokens", 1024), + ) + + self._agent = ToolCallingAgent( + tools=ALL_TOOLS, + model=self._model, + max_steps=self._cfg.get("max_agent_steps", 10), + verbosity_level=1, + ) + + logger.info("Агент готов") + + def run(self, user_input: str) -> str: + """ + Обработать команду пользователя. + Возвращает финальный текст ответа для TTS. + """ + logger.info(f"Агент: '{user_input}'") + + # Сохраняем в историю + self.memory.add_message("user", user_input) + + # Формируем промпт с текущей памятью + system = SYSTEM_PROMPT.format(memory_facts=self.memory.facts_as_text()) + + # smolagents принимает задачу и опциональный системный промпт + try: + result = self._agent.run( + user_input, + additional_args={"system_prompt_override": system}, + ) + response = str(result).strip() if result else "Готово" + except Exception as e: + logger.error(f"Ошибка агента: {e}") + response = "Произошла ошибка при выполнении команды" + + self.memory.add_message("assistant", response) + logger.info(f"Агент ответил: '{response}'") + return response diff --git a/cosmo/main.py b/cosmo/main.py new file mode 100644 index 0000000..ad7695a --- /dev/null +++ b/cosmo/main.py @@ -0,0 +1,144 @@ +""" +Cosmo — локальный голосовой ассистент. +Стек: openWakeWord → RealtimeSTT (Whisper + Silero VAD) → smolagents + Ollama → RealtimeTTS (Silero) + +Запуск: + bash run.sh + python cosmo/main.py +""" + +import sys +import os +import argparse +import threading + +import yaml + +# Указываем pydub где искать ffmpeg (установлен через imageio-ffmpeg) +try: + import imageio_ffmpeg + from pydub import AudioSegment + AudioSegment.converter = imageio_ffmpeg.get_ffmpeg_exe() +except Exception: + pass +from loguru import logger + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from cosmo.wake_word import WakeWordDetector +from cosmo.transcriber import Transcriber +from cosmo.memory import Memory +from cosmo.agent import Agent +from cosmo.tts import TTS + + + +def load_config(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def setup_logging(config: dict): + log_cfg = config.get("logging", {}) + level = log_cfg.get("level", "INFO") + log_file = log_cfg.get("file", "logs/cosmo.log") + os.makedirs(os.path.dirname(log_file), exist_ok=True) + logger.remove() + logger.add( + sys.stderr, level=level, colorize=True, + format="{time:HH:mm:ss} | {level: <8} | {message}", + ) + logger.add(log_file, level="DEBUG", rotation="10 MB", retention="7 days", encoding="utf-8") + + +class Cosmo: + def __init__(self, config: dict): + self.config = config + self.name = config["assistant"]["name"] + self._running = False + self._command_event = threading.Event() + + logger.info("Инициализирую модули...") + + self.tts = TTS(config) + self.transcriber = Transcriber(config) + self.memory = Memory() + self.agent = Agent(config, self.memory) + self.wake_word = WakeWordDetector(config, on_detected_callback=self._on_wake_word) + + def _on_wake_word(self): + logger.info(f"=== {self.name} активирован! ===") + self._command_event.set() + + def _process_command(self): + self.tts.say_async("Слушаю") + + # Partial results — печатаем в лог что слышим в реальном времени + def on_partial(text): + logger.debug(f"[partial] {text}") + + text = self.transcriber.record_and_transcribe(on_partial=on_partial) + + if not text.strip(): + self.tts.say_async("Не расслышал, попробуй ещё раз") + return + + # Агент обрабатывает команду и возвращает ответ для TTS + response = self.agent.run(text) + self.tts.say(response) + + def run(self): + self._running = True + self.wake_word.start() + + logger.info("=" * 50) + logger.info(f" {self.name} запущен!") + logger.info(f" Скажи '{self.name}' чтобы активировать.") + logger.info(f" Ctrl+C для выхода.") + logger.info("=" * 50) + + self.tts.say(f"{self.name} запущен") + + try: + while self._running: + triggered = self._command_event.wait(timeout=0.5) + if triggered and self._running: + self._command_event.clear() + try: + self._process_command() + finally: + self.wake_word.resume() + except KeyboardInterrupt: + logger.info("Ctrl+C — завершаю...") + finally: + self.shutdown() + + def shutdown(self): + logger.info("Завершаю работу...") + self._running = False + self.wake_word.stop() + self.transcriber.shutdown() + logger.info(f"{self.name} остановлен") + + +def main(): + parser = argparse.ArgumentParser(description="Cosmo — голосовой ассистент") + parser.add_argument("--config", default="config/config.yaml") + args = parser.parse_args() + + config_path = args.config + if not os.path.isabs(config_path): + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + config_path = os.path.join(project_root, config_path) + + if not os.path.exists(config_path): + print(f"Конфиг не найден: {config_path}") + sys.exit(1) + + config = load_config(config_path) + setup_logging(config) + Cosmo(config).run() + + +if __name__ == "__main__": + main() diff --git a/cosmo/memory.py b/cosmo/memory.py new file mode 100644 index 0000000..5cc0bce --- /dev/null +++ b/cosmo/memory.py @@ -0,0 +1,136 @@ +""" +Персистентная память ассистента. +Хранится в data/memory.json — LLM читает и пишет её через инструменты. + +Структура: +{ + "facts": { + "user.name": "Даниил", + "app.webstorm": "C:/Program Files/JetBrains/WebStorm.../webstorm64.exe", + "user.browser": "chrome", + ... + }, + "history": [ + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "..."} + ] +} +""" + +import json +import os +import threading +from datetime import datetime +from loguru import logger + +MEMORY_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "memory.json" +) + + +class Memory: + def __init__(self, path: str = MEMORY_PATH, history_limit: int = 20): + self.path = path + self.history_limit = history_limit + self._lock = threading.Lock() + self._data = {"facts": {}, "history": []} + self._load() + + # ------------------------------------------------------------------ + # Персистентность + # ------------------------------------------------------------------ + + def _load(self): + os.makedirs(os.path.dirname(self.path), exist_ok=True) + if os.path.exists(self.path): + try: + with open(self.path, "r", encoding="utf-8") as f: + self._data = json.load(f) + logger.info( + f"Память загружена: {len(self._data.get('facts', {}))} фактов, " + f"{len(self._data.get('history', []))} сообщений в истории" + ) + except Exception as e: + logger.warning(f"Не удалось загрузить память: {e}. Начинаю с чистой.") + self._data = {"facts": {}, "history": []} + else: + logger.info("Файл памяти не найден — создаю новый") + + def _save(self): + try: + with open(self.path, "w", encoding="utf-8") as f: + json.dump(self._data, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.error(f"Не удалось сохранить память: {e}") + + # ------------------------------------------------------------------ + # Факты (key-value долгосрочная память) + # ------------------------------------------------------------------ + + def get(self, key: str) -> str | None: + """Получить факт по ключу. Возвращает None если не найден.""" + with self._lock: + return self._data["facts"].get(key) + + def set(self, key: str, value: str): + """Сохранить факт. Перезаписывает если уже существует.""" + with self._lock: + self._data["facts"][key] = value + self._save() + logger.debug(f"Память: сохранено [{key}] = {value!r}") + + def delete(self, key: str) -> bool: + """Удалить факт. Возвращает True если был.""" + with self._lock: + existed = key in self._data["facts"] + if existed: + del self._data["facts"][key] + self._save() + return existed + + def list_facts(self, prefix: str = "") -> dict: + """Вернуть все факты, опционально отфильтрованные по префиксу.""" + with self._lock: + facts = self._data["facts"] + if prefix: + return {k: v for k, v in facts.items() if k.startswith(prefix)} + return dict(facts) + + def facts_as_text(self) -> str: + """Все факты в виде читаемого текста для системного промпта.""" + facts = self.list_facts() + if not facts: + return "Память пуста." + lines = [f" {k}: {v}" for k, v in sorted(facts.items())] + return "\n".join(lines) + + # ------------------------------------------------------------------ + # История разговора (краткосрочная, последние N сообщений) + # ------------------------------------------------------------------ + + def add_message(self, role: str, content: str): + """Добавить сообщение в историю (role: user/assistant/tool).""" + with self._lock: + self._data["history"].append({ + "role": role, + "content": content, + "ts": datetime.now().isoformat(timespec="seconds"), + }) + # Ограничиваем длину истории + if len(self._data["history"]) > self.history_limit: + self._data["history"] = self._data["history"][-self.history_limit:] + self._save() + + def get_history(self) -> list[dict]: + """Вернуть историю в формате для LLM API (без поля ts).""" + with self._lock: + return [ + {"role": m["role"], "content": m["content"]} + for m in self._data["history"] + ] + + def clear_history(self): + with self._lock: + self._data["history"] = [] + self._save() diff --git a/cosmo/tools.py b/cosmo/tools.py new file mode 100644 index 0000000..d0a8221 --- /dev/null +++ b/cosmo/tools.py @@ -0,0 +1,232 @@ +""" +Инструменты агента для smolagents. +Каждый инструмент — функция с декоратором @tool. +smolagents автоматически генерирует схему из docstring и type hints. +""" + +import os +import subprocess +import webbrowser +import urllib.parse +from loguru import logger +from smolagents import tool + +from cosmo.memory import Memory + +# Глобальная ссылка на память — устанавливается из agent.py +_memory: Memory | None = None + +def set_memory(mem: Memory): + global _memory + _memory = mem + + +# ------------------------------------------------------------------ +# Поиск Git Bash +# ------------------------------------------------------------------ + +_GIT_BASH_CANDIDATES = [ + "C:/Program Files/Git/bin/bash.exe", + "C:/Program Files (x86)/Git/bin/bash.exe", + os.path.expandvars("%LOCALAPPDATA%/Programs/Git/bin/bash.exe"), +] + +def _find_git_bash() -> str: + for path in _GIT_BASH_CANDIDATES: + if os.path.exists(path): + return path + try: + result = subprocess.run(["where", "bash"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + first = result.stdout.strip().splitlines()[0] + if "git" in first.lower(): + return first + except Exception: + pass + return "bash" + + +# ------------------------------------------------------------------ +# Инструменты +# ------------------------------------------------------------------ + +@tool +def run_shell(command: str) -> str: + """ + Выполнить команду в Git Bash и получить вывод. + Используй bash-синтаксис. Для запуска Windows-программ: start '' '/c/path/app.exe' + или cmd //c start. Для поиска программ: which, find, cmd //c where. + + Args: + command: bash команда для выполнения + """ + bash = _find_git_bash() + logger.info(f"[run_shell] {command}") + try: + result = subprocess.run( + [bash, "-c", command], + capture_output=True, + text=True, + timeout=20, + encoding="utf-8", + errors="replace", + ) + output = result.stdout.strip() + stderr = result.stderr.strip() + if result.returncode != 0 and stderr: + return f"[returncode={result.returncode}]\nstdout: {output}\nstderr: {stderr}" + return output if output else f"[выполнено, returncode={result.returncode}]" + except FileNotFoundError: + return "[ошибка: Git Bash не найден]" + except subprocess.TimeoutExpired: + return "[таймаут 20с]" + except Exception as e: + return f"[ошибка: {e}]" + + +@tool +def find_program(name: str) -> str: + """ + Найти программу или файл на Windows по имени. + Ищет в PATH, Program Files, AppData и реестре. + Используй когда не знаешь точный путь к программе. + + Args: + name: имя программы без расширения, например 'webstorm' или 'chrome' + """ + stem = name.lower().strip().replace(".exe", "") + logger.info(f"[find_program] {stem}") + + steps = [ + f"which {stem} 2>/dev/null", + f"cmd //c where {stem} 2>/dev/null", + f"find '/c/Program Files' -iname '{stem}*.exe' 2>/dev/null | head -3", + f"find '/c/Program Files (x86)' -iname '{stem}*.exe' 2>/dev/null | head -3", + f"find \"$LOCALAPPDATA\" -iname '{stem}*.exe' 2>/dev/null | head -3", + f"cmd //c 'reg query \"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{stem}.exe\" /ve 2>nul'", + ] + + results = [] + for cmd in steps: + out = run_shell(cmd) + if out and not out.startswith("[") and len(out) > 3: + results.append(out.strip()) + + if results: + return "Найдено:\n" + "\n".join(results) + return f"Программа '{name}' не найдена. Попробуй другое имя." + + +@tool +def open_browser(url: str, search: bool = False) -> str: + """ + Открыть URL в браузере или выполнить поиск в Google. + + Args: + url: полный URL (https://...) или поисковый запрос если search=True + search: если True — выполнить поиск Google по тексту в url + """ + if search: + url = "https://www.google.com/search?q=" + urllib.parse.quote(url) + elif not url.startswith(("http://", "https://")): + url = "https://" + url + webbrowser.open(url) + logger.info(f"[open_browser] {url}") + return f"Открыт браузер: {url}" + + +@tool +def read_file(path: str) -> str: + """ + Прочитать содержимое текстового файла. + + Args: + path: абсолютный путь к файлу + """ + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read(8000) + except FileNotFoundError: + return f"Файл не найден: {path}" + except Exception as e: + return f"Ошибка чтения: {e}" + + +@tool +def write_file(path: str, content: str) -> str: + """ + Записать текст в файл (создаёт или перезаписывает). + + Args: + path: абсолютный путь к файлу + content: содержимое файла + """ + try: + dir_path = os.path.dirname(path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return f"Файл записан: {path}" + except Exception as e: + return f"Ошибка записи: {e}" + + +@tool +def memory_set(key: str, value: str) -> str: + """ + Сохранить факт в долгосрочную память ассистента. + Используй для запоминания путей программ, предпочтений пользователя, часто используемых команд. + Ключи: 'app.название', 'user.имя', 'pref.что-то'. + + Args: + key: ключ факта, например 'app.webstorm' или 'user.name' + value: значение для сохранения + """ + if _memory is None: + return "Память не инициализирована" + _memory.set(key, value) + return f"Запомнено: {key} = {value!r}" + + +@tool +def memory_get(key: str) -> str: + """ + Получить сохранённый факт из долгосрочной памяти по ключу. + + Args: + key: ключ факта, например 'app.webstorm' + """ + if _memory is None: + return "Память не инициализирована" + value = _memory.get(key) + return f"{key} = {value!r}" if value else f"Факт '{key}' не найден" + + +@tool +def memory_list(prefix: str = "") -> str: + """ + Показать все факты из памяти, опционально по префиксу. + + Args: + prefix: префикс для фильтрации, например 'app.' чтобы видеть пути программ + """ + if _memory is None: + return "Память не инициализирована" + facts = _memory.list_facts(prefix) + if not facts: + return "Память пуста" if not prefix else f"Нет фактов с префиксом '{prefix}'" + return "\n".join(f"{k}: {v}" for k, v in sorted(facts.items())) + + +# Список всех инструментов для передачи в агент +ALL_TOOLS = [ + run_shell, + find_program, + open_browser, + read_file, + write_file, + memory_set, + memory_get, + memory_list, +] diff --git a/cosmo/tools_mac.py b/cosmo/tools_mac.py new file mode 100644 index 0000000..5694d06 --- /dev/null +++ b/cosmo/tools_mac.py @@ -0,0 +1,206 @@ +""" +Инструменты агента для smolagents — macOS версия. +Отличия от Windows: нативный bash, поиск через mdfind/Spotlight, +запуск приложений через 'open -a', нет реестра. +""" + +import os +import subprocess +import webbrowser +import urllib.parse +from loguru import logger +from smolagents import tool + +from cosmo.memory import Memory + +_memory: Memory | None = None + +def set_memory(mem: Memory): + global _memory + _memory = mem + + +# ------------------------------------------------------------------ +# Инструменты +# ------------------------------------------------------------------ + +@tool +def run_shell(command: str) -> str: + """ + Выполнить команду в bash и получить вывод. + На macOS доступны все стандартные unix-команды. + Для запуска приложений используй: open -a "AppName" или open /path/to/app. + Для поиска файлов: mdfind, find, which. + + Args: + command: bash команда для выполнения + """ + logger.info(f"[run_shell] {command}") + try: + result = subprocess.run( + ["bash", "-c", command], + capture_output=True, + text=True, + timeout=20, + encoding="utf-8", + errors="replace", + ) + output = result.stdout.strip() + stderr = result.stderr.strip() + if result.returncode != 0 and stderr: + return f"[returncode={result.returncode}]\nstdout: {output}\nstderr: {stderr}" + return output if output else f"[выполнено, returncode={result.returncode}]" + except subprocess.TimeoutExpired: + return "[таймаут 20с]" + except Exception as e: + return f"[ошибка: {e}]" + + +@tool +def find_program(name: str) -> str: + """ + Найти программу или приложение на macOS по имени. + Ищет в PATH, /Applications, ~/Applications и через Spotlight (mdfind). + + Args: + name: имя программы, например 'webstorm', 'chrome', 'cursor' + """ + stem = name.strip() + logger.info(f"[find_program] {stem}") + + steps = [ + # which — ищет в PATH + f"which {stem} 2>/dev/null", + # Spotlight — самый надёжный способ найти .app + f"mdfind 'kMDItemKind == \"Application\"' | grep -i '{stem}' | head -3", + # /Applications напрямую + f"find /Applications -maxdepth 2 -iname '*{stem}*.app' 2>/dev/null | head -3", + # ~/Applications + f"find ~/Applications -maxdepth 2 -iname '*{stem}*.app' 2>/dev/null | head -3", + # Homebrew bin + f"find /opt/homebrew/bin /usr/local/bin -iname '*{stem}*' 2>/dev/null | head -3", + ] + + results = [] + for cmd in steps: + out = run_shell(cmd) + if out and not out.startswith("[") and len(out) > 3: + results.append(out.strip()) + + if results: + return "Найдено:\n" + "\n".join(results) + return f"Программа '{name}' не найдена." + + +@tool +def open_browser(url: str, search: bool = False) -> str: + """ + Открыть URL в браузере или выполнить поиск в Google. + + Args: + url: полный URL (https://...) или поисковый запрос если search=True + search: если True — выполнить поиск Google по тексту в url + """ + if search: + url = "https://www.google.com/search?q=" + urllib.parse.quote(url) + elif not url.startswith(("http://", "https://")): + url = "https://" + url + webbrowser.open(url) + logger.info(f"[open_browser] {url}") + return f"Открыт браузер: {url}" + + +@tool +def read_file(path: str) -> str: + """ + Прочитать содержимое текстового файла. + + Args: + path: абсолютный путь к файлу + """ + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read(8000) + except FileNotFoundError: + return f"Файл не найден: {path}" + except Exception as e: + return f"Ошибка чтения: {e}" + + +@tool +def write_file(path: str, content: str) -> str: + """ + Записать текст в файл (создаёт или перезаписывает). + + Args: + path: абсолютный путь к файлу + content: содержимое файла + """ + try: + dir_path = os.path.dirname(path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return f"Файл записан: {path}" + except Exception as e: + return f"Ошибка записи: {e}" + + +@tool +def memory_set(key: str, value: str) -> str: + """ + Сохранить факт в долгосрочную память ассистента. + Используй для запоминания путей программ, предпочтений пользователя. + Ключи: 'app.название', 'user.имя', 'pref.что-то'. + + Args: + key: ключ факта, например 'app.webstorm' + value: значение для сохранения + """ + if _memory is None: + return "Память не инициализирована" + _memory.set(key, value) + return f"Запомнено: {key} = {value!r}" + + +@tool +def memory_get(key: str) -> str: + """ + Получить сохранённый факт из долгосрочной памяти по ключу. + + Args: + key: ключ факта, например 'app.webstorm' + """ + if _memory is None: + return "Память не инициализирована" + value = _memory.get(key) + return f"{key} = {value!r}" if value else f"Факт '{key}' не найден" + + +@tool +def memory_list(prefix: str = "") -> str: + """ + Показать все факты из памяти, опционально по префиксу. + + Args: + prefix: префикс для фильтрации, например 'app.' + """ + if _memory is None: + return "Память не инициализирована" + facts = _memory.list_facts(prefix) + if not facts: + return "Память пуста" if not prefix else f"Нет фактов с префиксом '{prefix}'" + return "\n".join(f"{k}: {v}" for k, v in sorted(facts.items())) + + +ALL_TOOLS = [ + run_shell, + find_program, + open_browser, + read_file, + write_file, + memory_set, + memory_get, + memory_list, +] diff --git a/cosmo/transcriber.py b/cosmo/transcriber.py new file mode 100644 index 0000000..09bf588 --- /dev/null +++ b/cosmo/transcriber.py @@ -0,0 +1,87 @@ +""" +STT модуль на базе RealtimeSTT. +Использует faster-whisper + Silero VAD под капотом. +Поддерживает стриминг — partial transcriptions во время речи. +""" + +import threading +from RealtimeSTT import AudioToTextRecorder +from loguru import logger + + +class Transcriber: + def __init__(self, config: dict): + whisper_cfg = config["whisper"] + audio_cfg = config["audio"] + + self._recorder: AudioToTextRecorder | None = None + self._config = { + "model": whisper_cfg["model_size"], + "language": whisper_cfg["language"], + "device": whisper_cfg["device"], + "compute_type": whisper_cfg["compute_type"], + # Silero VAD параметры + "silero_sensitivity": 0.4, + "webrtc_sensitivity": 3, + "post_speech_silence_duration": audio_cfg["silence_duration"], + "min_length_of_recording": 0.3, + "min_gap_between_recordings": 0.01, + # Отключаем wake word в RealtimeSTT — используем свой + "wakeword_backend": "none", + # Не запускать в режиме непрерывного прослушивания + "use_microphone": True, + "spinner": False, + "level": 0, # минимальный лог уровень внутри RealtimeSTT + } + + logger.info( + f"Инициализирую RealtimeSTT: модель={whisper_cfg['model_size']}, " + f"device={whisper_cfg['device']}, compute={whisper_cfg['compute_type']}" + ) + self._init_recorder() + + def _init_recorder(self): + try: + self._recorder = AudioToTextRecorder(**self._config) + logger.info("RealtimeSTT готов") + except Exception as e: + logger.error(f"Ошибка инициализации RealtimeSTT: {e}") + raise + + def record_and_transcribe(self, on_partial: callable = None) -> str: + """ + Записывает команду и транскрибирует. + on_partial(text) — опциональный колбэк для частичных результатов. + Возвращает финальный текст. + """ + if self._recorder is None: + self._init_recorder() + + result_holder = [] + done_event = threading.Event() + + def on_text(text: str): + result_holder.append(text) + done_event.set() + + # Partial results — показываем что слышим в реальном времени + if on_partial: + self._recorder.on_realtime_transcription_update = on_partial + + logger.info("Слушаю команду...") + self._recorder.text(on_text) + done_event.wait(timeout=12.0) + + text = result_holder[0].strip() if result_holder else "" + if text: + logger.info(f"Транскрипция: '{text}'") + else: + logger.info("Команда не распознана (тишина или таймаут)") + return text + + def shutdown(self): + if self._recorder: + try: + self._recorder.shutdown() + except Exception: + pass diff --git a/cosmo/tts.py b/cosmo/tts.py new file mode 100644 index 0000000..1742155 --- /dev/null +++ b/cosmo/tts.py @@ -0,0 +1,79 @@ +""" +TTS модуль на базе Silero V4 (torch.hub) + sounddevice. +Silero — лучший русскоязычный офлайн TTS. +Модель скачивается автоматически при первом запуске (~50 MB). +""" + +import threading +import numpy as np +import sounddevice as sd +from loguru import logger + +try: + import torch + TORCH_AVAILABLE = True +except ImportError: + TORCH_AVAILABLE = False + + +class TTS: + def __init__(self, config: dict): + tts_cfg = config.get("tts", {}) + self.enabled = tts_cfg.get("enabled", True) + self.speaker = tts_cfg.get("silero_speaker", "xenia") + self.sample_rate = tts_cfg.get("sample_rate", 48000) + self._lock = threading.Lock() + self._model = None + + if not self.enabled: + return + if not TORCH_AVAILABLE: + logger.warning("torch не установлен — TTS отключён") + self.enabled = False + return + + self._load_model() + + def _load_model(self): + try: + logger.info(f"Загружаю Silero TTS (голос: {self.speaker}, {self.sample_rate} Hz)...") + # torch.hub кэширует модель в ~/.cache/torch/hub + model, _ = torch.hub.load( + repo_or_dir="snakers4/silero-models", + model="silero_tts", + language="ru", + speaker="v4_ru", + trust_repo=True, + ) + self._model = model + logger.info("Silero TTS готов") + except Exception as e: + logger.error(f"Ошибка загрузки Silero TTS: {e}") + logger.warning("TTS отключён") + self.enabled = False + + def say(self, text: str): + """Произнести текст синхронно.""" + if not self.enabled or self._model is None: + logger.info(f"[TTS]: {text}") + return + + logger.debug(f"TTS: '{text}'") + with self._lock: + try: + with torch.no_grad(): + audio = self._model.apply_tts( + text=text, + speaker=self.speaker, + sample_rate=self.sample_rate, + ) + audio_np = audio.numpy() if hasattr(audio, "numpy") else np.array(audio) + sd.play(audio_np, samplerate=self.sample_rate) + sd.wait() + except Exception as e: + logger.error(f"Ошибка TTS: {e}") + + def say_async(self, text: str): + """Произнести текст асинхронно.""" + t = threading.Thread(target=self.say, args=(text,), daemon=True) + t.start() diff --git a/cosmo/wake_word.py b/cosmo/wake_word.py new file mode 100644 index 0000000..86aa1f3 --- /dev/null +++ b/cosmo/wake_word.py @@ -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 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..4db9226 --- /dev/null +++ b/install.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -e + +echo "============================================" +echo " Установка зависимостей Cosmo" +echo "============================================" + +# Проверяем наличие Python +if ! command -v python &>/dev/null; then + echo "ОШИБКА: Python не найден. Установи Python 3.10+" + exit 1 +fi + +echo "[1/4] Обновляю pip..." +python -m pip install --upgrade pip + +echo "[2/4] Устанавливаю основные зависимости..." +pip install -r requirements.txt + +echo "[3/4] Устанавливаю faster-whisper с поддержкой CUDA..." +pip install faster-whisper + +echo "[4/4] Устанавливаю openwakeword..." +pip install openwakeword + +echo "" +echo "============================================" +echo " Установка завершена!" +echo "" +echo " Следующие шаги:" +echo " 1. Запусти LM Studio и загрузи модель" +echo " 2. Включи Local Server в LM Studio (порт 1234)" +echo " 3. Запусти: bash run.sh" +echo "============================================" +read -p "Нажми Enter для выхода..." diff --git a/install_mac.sh b/install_mac.sh new file mode 100644 index 0000000..897bedd --- /dev/null +++ b/install_mac.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -e + +echo "============================================" +echo " Установка Cosmo на macOS" +echo "============================================" + +# --- Python --- +if ! command -v python3 &>/dev/null; then + echo "ОШИБКА: Python3 не найден." + echo "Установи через Homebrew: brew install python@3.11" + exit 1 +fi + +PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") +echo "Python: $PYTHON_VERSION" + +# --- Homebrew зависимости --- +if command -v brew &>/dev/null; then + echo "[1/5] Устанавливаю системные зависимости через Homebrew..." + brew install portaudio ffmpeg 2>/dev/null || true +else + echo "Homebrew не найден — пропускаю системные зависимости." + 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 "[3/5] Устанавливаю зависимости..." +python3 -m pip install -r requirements.txt + +echo "[4/5] Устанавливаю faster-whisper..." +# На Mac (Apple Silicon) используем CPU compute type +python3 -m 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 + +echo "" +echo "============================================" +echo " Установка завершена!" +echo "" +echo " Следующие шаги:" +echo " 1. Установи и запусти Ollama: https://ollama.com" +echo " 2. Скачай модель: ollama pull qwen2.5:7b" +echo " 3. Запусти Cosmo: bash run_mac.sh" +echo "============================================" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f4cedbb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +# Wake word +openwakeword==0.6.0 + +# STT — стриминг с Silero VAD +RealtimeSTT==0.3.104 + +# TTS — Silero V4 для русского языка +RealtimeTTS==0.6.1 +torch>=2.0.0 # нужен для Silero (CPU inference) + +# Agent framework +smolagents==1.11.0 +ollama==0.4.4 # официальный Python клиент Ollama + +# Память и конфиг +pyyaml==6.0.2 +loguru==0.7.2 + +# Инструменты агента +psutil==6.0.0 +pyautogui==0.9.54 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..01af6ce --- /dev/null +++ b/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")" +python cosmo/main.py "$@" diff --git a/run_mac.sh b/run_mac.sh new file mode 100644 index 0000000..8005e66 --- /dev/null +++ b/run_mac.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$0")" + +# Проверяем что Ollama запущен +if ! curl -s http://localhost:11434 &>/dev/null; then + echo "Ollama не запущен. Запускаю..." + ollama serve &>/dev/null & + sleep 2 +fi + +# Запускаем с Mac-конфигом +COSMO_PLATFORM=mac python3 cosmo/main.py --config config/config_mac.yaml "$@" diff --git a/train_wakeword/Dockerfile b/train_wakeword/Dockerfile new file mode 100644 index 0000000..c50ac4a --- /dev/null +++ b/train_wakeword/Dockerfile @@ -0,0 +1,92 @@ +# Dockerfile для обучения wake word модели openWakeWord +# Python 3.11 + torch 2.5 (последний совместимый с py3.11) + рабочие зависимости 2026 +FROM python:3.11-slim + +WORKDIR /app + +# Системные зависимости (включая build-essential для webrtcvad) +RUN apt-get update && apt-get install -y \ + git wget curl ffmpeg libsndfile1 \ + build-essential python3-dev \ + && 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) +RUN pip install --no-cache-dir \ + torch==2.5.0 \ + torchaudio==2.5.0 \ + --index-url https://download.pytorch.org/whl/cpu + +# Зависимости обучения с совместимыми версиями +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 \ + 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 + +RUN pip install --no-cache-dir -e /openWakeWord + +# Патч: заменяем onnx_tf на onnx2tf в train.py +RUN python - <<'EOF' +import re, 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 +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) для генерации примеров +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" + +RUN mkdir -p /data /output /samples + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/train_wakeword/cosmo_config.yaml b/train_wakeword/cosmo_config.yaml new file mode 100644 index 0000000..d0becee --- /dev/null +++ b/train_wakeword/cosmo_config.yaml @@ -0,0 +1,53 @@ +# Конфиг для обучения wake word модели "Hey Cosmo" +# Документация: https://github.com/dscripka/openWakeWord + +model_name: "hey_cosmo" +output_dir: "/output" + +# Целевая фраза — "hey cosmo" работает лучше чем просто "cosmo" +target_phrase: + - "hey cosmo" + - "cosmo" + +# Похожие слова для улучшения устойчивости к ложным срабатываниям +custom_negative_phrases: + - "hey cosmos" + - "hey cosmic" + - "hey cosplay" + - "hey presto" + - "hey como" + - "hey koz" + - "cozmo" + +# Количество синтетических примеров +n_samples: 10000 +n_samples_val: 1000 +tts_batch_size: 25 + +# Аугментация +augmentation_batch_size: 16 +augmentation_rounds: 2 + +# Пути внутри Docker контейнера +piper_sample_generator_path: "/piper-sample-generator" +false_positive_validation_data_path: "/data/validation_set_features.npy" + +feature_data_files: + "ACAV100M_sample": "/data/openwakeword_features_ACAV100M_2000_hrs_16bit.npy" + +batch_n_per_class: + "ACAV100M_sample": 1024 + "adversarial_negative": 50 + "positive": 50 + +# Архитектура модели +model_type: "dnn" +layer_size: 32 +steps: 50000 + +# Цели качества +max_negative_weight: 1500 +target_false_positives_per_hour: 0.5 +target_accuracy: 0.7 +target_recall: 0.5 +lr: 0.0001 diff --git a/train_wakeword/entrypoint.sh b/train_wakeword/entrypoint.sh new file mode 100644 index 0000000..b2f6a7f --- /dev/null +++ b/train_wakeword/entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +echo "============================================" +echo " Обучение wake word модели 'Hey Cosmo'" +echo "============================================" + +CONFIG="/app/cosmo_config.yaml" +OWW="/openWakeWord/openwakeword/train.py" + +# Шаг 1: Генерация синтетических аудио примеров через TTS +echo "" +echo "[1/3] Генерирую синтетические примеры через TTS..." +python "$OWW" --training_config "$CONFIG" --generate_clips +echo " Готово." + +# Шаг 2: Аугментация (шумы, реверберация, разные условия) +echo "" +echo "[2/3] Аугментирую примеры (шумы, реверберация)..." +python "$OWW" --training_config "$CONFIG" --augment_clips +echo " Готово." + +# Шаг 3: Обучение модели +echo "" +echo "[3/3] Обучаю модель..." +python "$OWW" --training_config "$CONFIG" --train_model +echo " Готово." + +# Копируем результат +echo "" +echo "============================================" +if ls /output/hey_cosmo*.onnx 1>/dev/null 2>&1; then + echo " Модель обучена успешно!" + echo " Файлы в папке models/:" + ls -lh /output/*.onnx 2>/dev/null || true + ls -lh /output/*.tflite 2>/dev/null || true +else + echo " ОШИБКА: .onnx файл не найден в /output" + echo " Проверь логи выше." + exit 1 +fi +echo "============================================" diff --git a/train_wakeword/record_samples.py b/train_wakeword/record_samples.py new file mode 100644 index 0000000..ead9534 --- /dev/null +++ b/train_wakeword/record_samples.py @@ -0,0 +1,114 @@ +""" +Запись голосовых примеров для обучения wake word модели. +Запускай: python train_wakeword/record_samples.py + +Скрипт записывает N примеров слова "Hey Cosmo" с паузами между ними. +Файлы сохраняются в train_wakeword/samples/positive/ +""" + +import os +import sys +import time +import wave +import struct +import threading + +try: + import pyaudio +except ImportError: + print("Установи pyaudio: pip install pyaudio") + sys.exit(1) + +# --- Настройки --- +WAKE_WORD = "Hey Cosmo" +N_SAMPLES = 30 # сколько примеров записать +RECORD_SECS = 2.0 # длина одной записи (сек) +PAUSE_SECS = 2.0 # пауза между записями (сек) +SAMPLE_RATE = 16000 +CHANNELS = 1 +CHUNK = 512 +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "samples", "positive") + +os.makedirs(OUTPUT_DIR, exist_ok=True) + +def record_clip(pa: pyaudio.PyAudio, filename: str, duration: float): + stream = pa.open( + format=pyaudio.paInt16, + channels=CHANNELS, + rate=SAMPLE_RATE, + input=True, + frames_per_buffer=CHUNK, + ) + frames = [] + n_chunks = int(SAMPLE_RATE / CHUNK * duration) + for _ in range(n_chunks): + frames.append(stream.read(CHUNK, exception_on_overflow=False)) + stream.stop_stream() + stream.close() + + with wave.open(filename, "wb") as wf: + wf.setnchannels(CHANNELS) + wf.setsampwidth(pa.get_sample_size(pyaudio.paInt16)) + wf.setframerate(SAMPLE_RATE) + wf.writeframes(b"".join(frames)) + +def countdown(seconds: int): + for i in range(seconds, 0, -1): + print(f"\r {i}...", end="", flush=True) + time.sleep(1) + print("\r Говори! ", end="", flush=True) + +def main(): + # Считаем уже записанные файлы + existing = [f for f in os.listdir(OUTPUT_DIR) if f.endswith(".wav")] + start_idx = len(existing) + + if start_idx >= N_SAMPLES: + print(f"Уже записано {start_idx} примеров. Для перезаписи удали папку {OUTPUT_DIR}") + return + + pa = pyaudio.PyAudio() + + print("=" * 50) + print(f" Запись примеров wake word: \"{WAKE_WORD}\"") + print(f" Нужно записать: {N_SAMPLES} примеров") + print(f" Уже есть: {start_idx}") + print(f" Длина каждой записи: {RECORD_SECS} сек") + print("=" * 50) + print() + print("Инструкция:") + print(" - Говори чётко и естественно") + + print(" - Меняй интонацию, темп, громкость") + print(" - Можно говорить чуть тише / громче / быстрее") + print(" - Представь что реально обращаешься к ассистенту") + print() + input(" Нажми Enter когда готов начать...") + print() + + for i in range(start_idx, N_SAMPLES): + num = i + 1 + filename = os.path.join(OUTPUT_DIR, f"hey_cosmo_{num:03d}.wav") + + print(f"[{num:2d}/{N_SAMPLES}] Приготовься... ", end="", flush=True) + countdown(2) + + record_clip(pa, filename, RECORD_SECS) + print(f" ✓ записано") + + if num < N_SAMPLES: + time.sleep(PAUSE_SECS) + + pa.terminate() + + print() + print("=" * 50) + print(f" Готово! Записано {N_SAMPLES} примеров.") + print(f" Папка: {OUTPUT_DIR}") + print() + print(" Следующий шаг:") + print(" bash train_wakeword/train.sh") + print("=" * 50) + +if __name__ == "__main__": + main() diff --git a/train_wakeword/train.sh b/train_wakeword/train.sh new file mode 100644 index 0000000..36ca085 --- /dev/null +++ b/train_wakeword/train.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +MODELS_DIR="$PROJECT_DIR/models" +SAMPLES_DIR="$SCRIPT_DIR/samples" +DATA_DIR="$SCRIPT_DIR/docker_data" + +echo "============================================" +echo " Cosmo Wake Word — обучение модели" +echo "============================================" +echo "" + +# Проверяем Docker +if ! command -v docker &>/dev/null; then + echo "ОШИБКА: Docker не найден." + echo "Установи Docker Desktop: https://www.docker.com/products/docker-desktop/" + exit 1 +fi + +if ! docker info &>/dev/null; then + echo "ОШИБКА: Docker не запущен. Запусти Docker Desktop и попробуй снова." + exit 1 +fi + +# Проверяем что есть записанные примеры +POSITIVE_DIR="$SAMPLES_DIR/positive" +if [ ! -d "$POSITIVE_DIR" ] || [ -z "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; then + echo "Записанные примеры не найдены." + echo "Сначала запусти запись голоса:" + echo " python train_wakeword/record_samples.py" + echo "" + read -p "Продолжить без записанных примеров? (используются только TTS) [y/N]: " yn + case "$yn" in + [Yy]*) echo "Продолжаем с только TTS примерами..." ;; + *) exit 0 ;; + esac +fi + +mkdir -p "$MODELS_DIR" "$DATA_DIR" + +# Собираем Docker образ +echo "[1/4] Собираю Docker образ (первый раз ~5-10 мин)..." +docker build -t cosmo-wakeword-trainer "$SCRIPT_DIR" --quiet +echo " Образ готов." +echo "" + +# Скачиваем датасет негативных примеров если нет +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 " Это займёт время в зависимости от скорости интернета." + 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 " Датасет готов." +else + echo "[2/4] Негативный датасет уже скачан. Пропускаю." +fi +echo "" + +# Запускаем обучение +echo "[3/4] Запускаю обучение в Docker..." +echo " Это займёт ~30-60 минут." +echo "" + +SAMPLES_MOUNT="" +if [ -d "$POSITIVE_DIR" ] && [ -n "$(ls "$POSITIVE_DIR"/*.wav 2>/dev/null)" ]; then + SAMPLES_MOUNT="-v $POSITIVE_DIR:/samples/positive" +fi + +docker run --rm \ + -v "$SCRIPT_DIR/cosmo_config.yaml:/app/cosmo_config.yaml" \ + -v "$DATA_DIR:/data" \ + -v "$MODELS_DIR:/output" \ + $SAMPLES_MOUNT \ + cosmo-wakeword-trainer + +echo "" +echo "[4/4] Копирую модель в проект..." + +# Ищем готовую модель +ONNX_FILE=$(ls "$MODELS_DIR"/*.onnx 2>/dev/null | head -1) + +if [ -n "$ONNX_FILE" ]; then + echo "" + echo "============================================" + echo " Готово! Модель сохранена:" + echo " $ONNX_FILE" + echo "" + echo " Обновляю wake_word детектор..." + # Обновляем путь в конфиге + MODEL_FILENAME=$(basename "$ONNX_FILE") + sed -i "s|wakeword_models=\[\"hey_jarvis\"\]|wakeword_models=[\"models/$MODEL_FILENAME\"]|g" \ + "$PROJECT_DIR/cosmo/wake_word.py" 2>/dev/null || true + echo " Теперь запускай: bash run.sh" + echo " и говори 'Hey Cosmo' для активации!" + echo "============================================" +else + echo "ОШИБКА: .onnx файл не найден в $MODELS_DIR" + echo "Проверь логи Docker выше." + exit 1 +fi