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:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -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
|
||||
208
CLAUDE.md
Normal file
208
CLAUDE.md
Normal file
@@ -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('открой браузер'))
|
||||
"
|
||||
```
|
||||
29
config/config.yaml
Normal file
29
config/config.yaml
Normal file
@@ -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"
|
||||
29
config/config_mac.yaml
Normal file
29
config/config_mac.yaml
Normal file
@@ -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"
|
||||
0
cosmo/__init__.py
Normal file
0
cosmo/__init__.py
Normal file
89
cosmo/agent.py
Normal file
89
cosmo/agent.py
Normal file
@@ -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
|
||||
144
cosmo/main.py
Normal file
144
cosmo/main.py
Normal file
@@ -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="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | {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()
|
||||
136
cosmo/memory.py
Normal file
136
cosmo/memory.py
Normal file
@@ -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()
|
||||
232
cosmo/tools.py
Normal file
232
cosmo/tools.py
Normal file
@@ -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,
|
||||
]
|
||||
206
cosmo/tools_mac.py
Normal file
206
cosmo/tools_mac.py
Normal file
@@ -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,
|
||||
]
|
||||
87
cosmo/transcriber.py
Normal file
87
cosmo/transcriber.py
Normal file
@@ -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
|
||||
79
cosmo/tts.py
Normal file
79
cosmo/tts.py
Normal file
@@ -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()
|
||||
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
|
||||
35
install.sh
Normal file
35
install.sh
Normal file
@@ -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 для выхода..."
|
||||
49
install_mac.sh
Normal file
49
install_mac.sh
Normal file
@@ -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 "============================================"
|
||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -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
|
||||
5
run.sh
Normal file
5
run.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
python cosmo/main.py "$@"
|
||||
14
run_mac.sh
Normal file
14
run_mac.sh
Normal file
@@ -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 "$@"
|
||||
92
train_wakeword/Dockerfile
Normal file
92
train_wakeword/Dockerfile
Normal file
@@ -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"]
|
||||
53
train_wakeword/cosmo_config.yaml
Normal file
53
train_wakeword/cosmo_config.yaml
Normal file
@@ -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
|
||||
42
train_wakeword/entrypoint.sh
Normal file
42
train_wakeword/entrypoint.sh
Normal file
@@ -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 "============================================"
|
||||
114
train_wakeword/record_samples.py
Normal file
114
train_wakeword/record_samples.py
Normal file
@@ -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()
|
||||
119
train_wakeword/train.sh
Normal file
119
train_wakeword/train.sh
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user