diff --git a/.env.example b/.env.example index 75869d6..0ee1a2d 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,12 @@ # OpenClaw Gateway — Cosmo +# Роутинг к агенту идёт через COSMO_SESSION_KEY, отдельный AGENT не нужен. GATEWAY_URL=http://192.168.31.103:18789 GATEWAY_TOKEN=your_openclaw_token_here -AGENT=openclaw/main VOICE_MODEL=openai/gpt-5.4-mini # OpenClaw Gateway — Люся LUSYA_GATEWAY_URL=http://192.168.31.103:18790 LUSYA_GATEWAY_TOKEN=your_openclaw_token_here -LUSYA_AGENT=openclaw/main LUSYA_VOICE_MODEL=openai/gpt-5.4-mini # STT (Groq) @@ -31,6 +30,17 @@ SILENCE_THRESHOLD=500 SILENCE_DURATION=1.5 MAX_DURATION=15 FOLLOWUP_TIMEOUT=8 +VAD_AGGRESSIVENESS=2 # webrtcvad 0..3, больше = строже + +# LLM +VOICE_MAX_TOKENS=300 +LLM_RETRIES=3 + +# Barge-in (прерывание TTS голосом). Работает только при разнесённых мике/колонке +# или в наушниках — иначе собственный TTS будет триггерить прерывание. +BARGE_IN_ENABLED=false +BARGE_IN_THRESHOLD=1500 # RMS выше SILENCE_THRESHOLD +BARGE_IN_WARMUP=0.8 # сек пропуска в начале TTS # Логирование LOG_FILE=errors.log diff --git a/CLAUDE.md b/CLAUDE.md index f3f763f..eab796d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,85 +1,83 @@ # Cosmo Voice Satellite -Голосовой ассистент дома — аналог Алисы через OpenClaw. Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway. +Голосовой ассистент дома — аналог Алисы, но поверх LLM (через OpenClaw Gateway). Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway. ## Архитектура ``` -┌─────────────┐ wake word ┌──────────────┐ STT (Groq) -│ Microphone │ ───────────► │ Satellite │ ──────────────► -└─────────────┘ └──────────────┘ │ - │ ▼ - │ ┌──────────────┐ - │ │ OpenClaw │ - │ │ Gateway │ - │ │ (N100 PC) │ - │ stream response └──────────────┘ +┌─────────────┐ wake word ┌──────────────┐ STT (Groq) +│ Microphone │ ────────────► │ Satellite │ ──────────────► OpenClaw Gateway +└─────────────┘ │ (Pi 5 / │ (N100, Proxmox) + │ Mac) │ ◄── LLM stream ── + └──────────────┘ │ + │ │ + ▼ TTS текст │ + ElevenLabs stream (mp3) │ + │ │ ▼ │ - ┌──────────────┐ │ - │ ElevenLabs │ ◄─────────────────┘ - │ TTS │ - └──────────────┘ - │ - ▼ mp3 stream - ┌──────────────┐ - │ mpv │ → speakers (BT) - └──────────────┘ + mpv (stdin) → speakers (BT/aux) ◄──┘ ``` +Сессия диалога теперь **на стороне OpenClaw** — satellite отправляет лишь `x-openclaw-session-key`, а история и память живут в gateway. Клиент stateless. + ## Инфраструктура - **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox -- **Cosmo Gateway**: порт `18789`, агент `openclaw/main` -- **Люся Gateway**: порт `18790`, агент `openclaw/wife` -- **Модель**: `openai/gpt-5.4-mini` (через `x-openclaw-model` header) +- **Cosmo Gateway**: порт `18789`, агент `openclaw/main`, session_key `agent:voice:voice:home` +- **Люся Gateway**: порт `18790`, агент `openclaw/wife`, session_key `agent:wife:voice:home` +- **Модель**: `openai/gpt-5.4-mini` (через `x-ocplatform-model` header; переопределяется через `VOICE_MODEL`) - **STT**: Groq API, `whisper-large-v3-turbo`, язык ru -- **TTS**: ElevenLabs, `eleven_flash_v2_5` (~75ms латентность) -- **Wake word**: Porcupine (на Pi), Enter (при разработке) +- **TTS**: ElevenLabs (`eleven_flash_v2_5` / `eleven_turbo_v2_5` / `eleven_multilingual_v2` — выбирается через `ELEVENLABS_MODEL`) +- **Wake word**: openwakeword (`.onnx`, обучается на своих голосах через `training/step_*.py`). Раньше закладывали Porcupine — отказались. ## Структура проекта ``` home-voice-assistant/ -├── .env # секреты (не в git) -├── .env.example # шаблон +├── .env # секреты (не в git) +├── .env.example # шаблон ├── requirements.txt -├── satellite.py # обёртка для запуска +├── satellite.py # обёртка для запуска ├── satellite/ -│ ├── __init__.py -│ ├── __main__.py # entry: python -m satellite [--wake] -│ ├── config.py # env, AGENTS dict, keep-alive sessions -│ ├── text.py # clean_for_speech, find_sentence_end -│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла) -│ ├── audio.py # record, record_with_timeout (VAD) -│ ├── tts.py # ElevenLabs streaming через mpv, barge-in -│ ├── llm.py # ask_agent_stream, Conversation (history) -│ └── modes.py # run_with_enter, run_with_porcupine +│ ├── __main__.py # entry: python -m satellite [--wake] +│ ├── config.py # env, AGENTS dict, keep-alive sessions +│ ├── text.py # clean_for_speech (+ pymorphy3/num2words для времени) +│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла) +│ ├── audio.py # record (RMS VAD) +│ ├── tts.py # ElevenLabs streaming через mpv stdin +│ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS +│ └── modes.py # run_with_enter / run_with_porcupine + /new через slash +├── record_wav.py # запись обучающих wav-ов для wake word +├── remove_silent.py # чистка тихих записей +├── training/ # пайплайн обучения wake word (не в git) └── deploy/ ├── setup.sh # установка на Raspberry Pi - └── cosmo-satellite.service # systemd unit + └── cosmo-satellite.service # systemd unit ``` -## Что важно знать +## Ключевые инварианты ### Сессии диалога -- **Одна сессия на день** для каждого агента. Это осознанное решение: каждая новая сессия в OpenClaw тяжёлая (чтение памяти, большой контекст). -- История хранится в `Conversation.messages[]` на клиенте и отправляется целиком с каждым запросом (stateless к серверу). -- Сброс сессии: фраза "начни новую сессию" / "сбрось историю" / "очисти контекст" — паттерны в `RESET_PATTERNS` в `llm.py`. -- Автосброс при смене даты (`Conversation.is_expired()`). -- `MAX_HISTORY=20` — лимит сообщений, чтобы не раздувать контекст. +- История и контекст **на стороне OpenClaw**. Клиент шлёт только текущий user-message + `x-openclaw-session-key`. +- Сброс: фраза «начни новую сессию» / «сбрось историю» / «очисти контекст» (паттерны в `llm.py::RESET_PATTERNS`) → `modes._handle_reset` делает прямой POST с `/new`. +- Автосброс по таймауту **пока не реализован** (кандидат Этапа 1). -### Оптимизации скорости (все уже внедрены) -1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py._make_session()`, переиспользуется TCP/TLS. +### Оптимизации скорости +1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py::_make_session()`, переиспользует TCP/TLS. 2. **Streaming TTS** — ElevenLabs аудио пайпится в `mpv` через stdin, играет пока генерируется. -3. **STT без диска** — PCM → WAV в `BytesIO` → Groq, без temp файлов. -4. **Barge-in** — `stop_speaking()` вызывается при каждой активации, убивает текущий mpv процесс. +3. **STT без диска** — PCM → WAV в `BytesIO` → Groq. +4. **Низкокачественный mp3 для речи** (`mp3_22050_32`) — меньше латентность без заметной потери качества для голоса. +5. **`optimize_streaming_latency=3`** в ElevenLabs convert — выдаёт первый чанк быстрее. ### Роутинг по wake word -В `modes.py::run_with_porcupine` Porcupine грузит оба wake word: -- index 0 = Cosmo → `AGENTS["cosmo"]` (:18789) -- index 1 = Люся → `AGENTS["lusya"]` (:18790) +`modes.py::run_with_porcupine` сейчас грузит только модель Cosmo. Код Люси закомментирован до того, как модель обучена. Когда готова: +- index 0 → `AGENTS["cosmo"]` (:18789) +- index 1 → `AGENTS["lusya"]` (:18790) -Каждый агент имеет свой `tts_voice` в ElevenLabs. +### Нормализация речи перед TTS +Два слоя защиты: +1. `text.py::clean_for_speech` — regex-правила: эмодзи, `**жирный**`, числа с плюсом/минусом, проценты, слэши, градусы, аббревиатуры (`т.е.`, `т.к.`), UNIT_SLASH (км/ч → «километров в час»), время `HH:MM` → прописью в правильном падеже через pymorphy3 + num2words. +2. `llm.py::strip_fillers` — режет фразы-заглушки («сейчас посмотрю», «дай секунду») которые агент генерит перед вызовом tool. ### Ошибки не должны ронять сервис Каждый слой (stt, tts, llm, audio, modes) ловит `Exception` и пишет в `errors.log` через `config.log`. Верхний уровень в modes.py ловит всё непредвиденное и продолжает цикл. @@ -88,103 +86,126 @@ home-voice-assistant/ ### macOS / Windows (разработка) ```bash -python -m venv .venv -# macOS/Linux: source .venv/bin/activate -# Windows: .venv\Scripts\activate +python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt cp .env.example .env # заполнить ключи python satellite.py # режим Enter (без wake word) -python satellite.py --wake # режим Porcupine (нужны .ppn + PORCUPINE_KEY) +python satellite.py --wake # режим openwakeword (нужна обученная .onnx) ``` ### Raspberry Pi (продакшн) ```bash sudo bash deploy/setup.sh -# далее: sudo systemctl start cosmo-satellite sudo journalctl -u cosmo-satellite -f ``` ## Зависимости системы -- **Python 3.12+** -- **portaudio** — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`) -- **mpv** — для воспроизведения TTS (`brew install mpv` / `apt install mpv`) -- **ffmpeg** — опционально, для совместимости форматов +- Python 3.12+ +- `portaudio` — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`) +- `mpv` — для воспроизведения TTS (`brew install mpv` / `apt install mpv`) -### Windows -- Python 3.12+ с pip -- `pip install pyaudio` — обычно работает через колеса pipwin или pre-built wheels. Если нет — `pip install pipwin && pipwin install pyaudio` -- mpv: скачать с [mpv.io](https://mpv.io/installation/), положить `mpv.exe` в PATH -- Porcupine работает и на Windows — wake word модель нужна под платформу windows (качать отдельную `.ppn`) - -## Переменные окружения - -Все в `.env`. Ключевые: +## Переменные окружения (ключевые) | Переменная | Что | -|-----------|-----| -| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway на N100 | -| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Токены авторизации | -| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw (`openclaw/main`, `openclaw/wife`) | -| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | Модель LLM для голоса | +|---|---| +| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway | +| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены | +| `AGENT`, `LUSYA_AGENT` | Имя агента (`openclaw/main`, `openclaw/wife`) | +| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) | +| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии OpenClaw | | `GROQ_API_KEY` | Groq для STT | -| `ELEVENLABS_API_KEY` | ElevenLabs TTS | +| `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL` | TTS | | `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID в ElevenLabs | -| `ELEVENLABS_MODEL` | `eleven_flash_v2_5` (быстрый) | -| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто. | -| `PORCUPINE_KEY`, `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Только для `--wake` режима | -| `SILENCE_THRESHOLD=500` | VAD: чувствительность (ниже = ловит тихую речь) | -| `SILENCE_DURATION=1.5` | Сек тишины = конец фразы | -| `FOLLOWUP_TIMEOUT=8` | Сек ожидания продолжения диалога | -| `MAX_HISTORY=20` | Макс. сообщений в сессии | +| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям wake word | +| `WAKE_THRESHOLD` | Порог активации (0..1, дефолт 0.5) | +| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто | +| `SILENCE_THRESHOLD`, `SILENCE_DURATION`, `MAX_DURATION`, `FOLLOWUP_TIMEOUT` | VAD | +| `TTS_MODE` | `full` (целостная интонация) или `stream` (быстрый старт, рваный) | +| `ECHO_WARMUP` | Сек пропуска в начале записи (гасит эхо от TTS) | ## Частые задачи -**Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID берётся на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library). +**Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID — на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library). Для русского лучше native-русские голоса, а не мультиязычные (не будет англоязычного акцента). -**Отладить VAD (ассистент не слышит / слушает слишком долго)**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек). +**Отладить VAD**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек). -**Добавить третьего агента**: в `config.py::AGENTS` новый ключ, в `modes.py::run_with_porcupine` добавить `WAKE_WORD_*` и `wake_word_map.append(...)`. +**Добавить третьего агента**: в `config.py::AGENTS` новый ключ + `WAKE_WORD_*` + раскомментировать блок в `modes.py::run_with_porcupine`. -**Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-openclaw-model`. Модель `openclaw/main` остаётся как agent (это маршрут в OpenClaw). +**Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-ocplatform-model`. Поле `model` в JSON остаётся `openclaw/main` (это имя агента, а не LLM). + +**Добавить фразу-заглушку**: в `llm.py::FILLER_PATTERNS` дополнить список. Эти фразы режутся из ответа перед TTS — агент генерит их до tool-call. ## Что НЕ делать - Не комитить `.env` (есть в `.gitignore`) -- Не возвращать fallback на macOS `say` — проект специально унифицирован на ElevenLabs + mpv -- Не создавать новую сессию Conversation на каждую активацию — это было в старой версии, сейчас одна сессия на день -- Не добавлять temp файлы для WAV/mp3 — всё идёт через `BytesIO` / stdin pipe +- Не возвращать `say`/`espeak` — проект унифицирован на ElevenLabs + mpv +- Не хранить историю диалога на клиенте — это делает OpenClaw по `session_key` +- Не создавать temp файлы для WAV/mp3 — всё через `BytesIO` / stdin pipe +- Не включать `style>0` и `speed≠1.0` в VoiceSettings — усиливают «иностранный» акцент и ломают просодию -## Тренировка своего wake word +## Тренировка wake word -Пайплайн в `training/`: -- `record_wav.py ` — запись 16kHz mono PCM 16-bit в `training/own_samples//` -- `training/step_1.py` … `step_5.py` — установка зависимостей, конвертация датасетов, генерация конфига, обучение, экспорт в `data/models/.onnx` -- `training/training_config.json` — параметры (`wake_word_list`, `use_own_samples`, штрафы, шаги) -- `training/openwakeword/` — форк openwakeword, `examples/custom_model.yml` — базовый шаблон конфига -- Под капотом: openwakeword (НЕ Porcupine, несмотря на легаси-имена в коде). Wake word работает через DNN-модель .onnx. +Пайплайн в `training/` (игнорируется в git): +- `record_wav.py [long ]` — запись 16kHz mono PCM 16-bit +- `remove_silent.py` — чистка + перенумерация +- `step_1.py … step_5.py` — зависимости, датасеты, конфиг, обучение, экспорт +- `training_config.json` — параметры (`wake_word_list`, `use_own_samples`, пенальти, шаги) +- Под капотом: openwakeword (DNN .onnx) -Реалистичные цифры для своего голоса: 500+ positive и 1000+ negative wav-файлов, иначе recall/FP/hour не сходятся. Negative должны включать фонетически близкие слова. +Реалистично для своего голоса: 500+ positive и 1000+ negative, иначе recall < 0.4. Negative должны включать фонетически близкие слова («космос», «просто»). -## Roadmap +## Состояние и планы -### Done -- [x] Модулизация satellite.py (audio/stt/llm/tts/modes/config) -- [x] ElevenLabs streaming TTS + mpv pipe -- [x] Keep-alive HTTP сессии, STT через BytesIO, barge-in -- [x] Сессии диалога (одна на день, MAX_HISTORY, паттерны сброса) -- [x] Пайплайн тренировки своего wake word на собственных записях +### ✅ Done +- [x] Модульная структура (`audio/stt/llm/tts/modes/config/text`) +- [x] ElevenLabs streaming + mpv pipe +- [x] Keep-alive HTTP сессии, STT через BytesIO +- [x] Серверные сессии OpenClaw через `x-openclaw-session-key` (не клиентская история) +- [x] Slash-команда `/new` на фразу «начни новую сессию» +- [x] Нормализация речи: числа, единицы, время через pymorphy3 + num2words +- [x] Пайплайн тренировки своего wake word + скрипты записи/чистки датасета +- [x] systemd unit и setup.sh для Pi 5 -### In progress -- [ ] Дообучение модели cosmo (на текущем датасете 300 pos / 117 neg метрики плохие — recall 25%, FP/hr 32). Нужно дозаписать данные. -- [ ] Подключить Люсю в `run_with_wakeword` (сейчас грузится только модель cosmo, lusya wake word не работает) +### 🚧 In progress / нужно сделать +- [ ] **Чистка**: удалить `start_barge_in_listener`/`was_barge_in` из `tts.py`, параметр `conv` из `llm.py`, импорты `sys`/`start_barge_in_listener`/`was_barge_in` из `modes.py` +- [ ] **`FOLLOWUP_TIMEOUT` реально применяется** — сейчас задекларирован, но после ответа ассистент ждёт полный `MAX_DURATION=15s` если пользователь молчит +- [ ] **Унифицировать дефолт session_key** в `config.py` и `.env.example` (сейчас `voice:home:cosmo` vs `agent:voice:voice:home`) +- [ ] **`max_tokens` → env** (`VOICE_MAX_TOKENS`), дефолт 300 +- [ ] **Дообучить модель cosmo до recall ≥ 0.7** (нужно 500+ positive + разнообразие) +- [ ] **Подключить Люсю в `run_with_porcupine`** (код закомментирован, готов к включению) +- [ ] **Проверить systemd autostart на Pi в проде** — unit есть, в прод не поставлен +- [ ] **logrotate / size-cap на `errors.log`** — растёт неограниченно -### Planned -- [ ] systemd autostart на Raspberry Pi (`deploy/cosmo-satellite.service` есть, но не проверен в проде) -- [ ] Home Assistant tool в OpenClaw воркспейсе (управление светом/температурой через голос) -- [ ] Real-time barge-in (прерывание по голосу во время озвучки, не только по новой активации) -- [ ] Контекст окружения в system prompt (время, погода, состояние устройств) -- [ ] Speaker identification (определять кто говорит без разных wake words) -- [ ] Проактивные уведомления (WebSocket от сервера → satellite сам начинает говорить) +### 📋 Roadmap Этап 2 — качество и надёжность +- [ ] **Автосброс OpenClaw сессии по таймауту** (>1 ч тишины → `/new`) +- [ ] **Retry с backoff** для gateway (3 попытки с экспонентой) +- [ ] **TTS-cache** для дежурных реплик («Начинаю новую сессию», «Не слышу», «Ошибка сервера») +- [ ] **Persistent PyAudio input stream** (не пересоздавать на каждый `record()`) +- [ ] **Заменить RMS-VAD на `webrtcvad` или `silero-vad`** — RMS не работает с фоновой музыкой +- [ ] **Whisper `prompt` параметр** с «Космо, Люся, OpenClaw» — для имён собственных +- [ ] **SYSTEM_PROMPT опционально на клиенте** — подсказка про TTS-friendly формат чисел/дат, если OpenClaw-агент без неё + +### 📋 Roadmap Этап 3 — новые фичи +- [ ] **Home Assistant tool** в OpenClaw: свет, климат, медиа голосом +- [ ] **Контекст окружения** в каждом запросе: время, комната, погода, кто говорит +- [ ] **Proactive notifications**: OpenClaw → WebSocket/SSE → satellite сам инициирует речь (таймеры, напоминания, входящее сообщение) +- [ ] **Realtime barge-in голосом** во время TTS (требует echo cancellation: speex AEC или SpeexDSP) +- [ ] **No-wake mode для доверенной комнаты** — VAD + whisper + intent filter без обязательного wake word +- [ ] **Streaming TTS пер-токен** — отправлять в TTS куски раньше чем целое предложение, с правильными интонационными точками + +### 📋 Roadmap Этап 4 — амбициозное +- [ ] **Speaker identification** (`pyannote.audio` / `resemblyzer`) — разные персонализации по голосу +- [ ] **Multi-room координация** — MQTT/gRPC между сателлитами, отвечает тот, кто слышит громче +- [ ] **Локальный fallback LLM** на Pi (phi/llama) когда gateway недоступен — базовые команды без облака +- [ ] **Камера + vision** — агент видит кто в комнате, что происходит +- [ ] **Voice-memory hooks UX** — голосовые команды «запомни», «забудь» (OpenClaw уже умеет, нужен голосовой слой) + +## Известные ограничения + +- **Нет echo cancellation** — если колонки близко к микрофону (особенно BT колонки на Pi), TTS может триггерить wake-модель. Mitigation: разносить колонку и мик, поднимать `WAKE_THRESHOLD`, использовать наушники при отладке. +- **VAD не отличает голос от музыки/ТВ** — если что-то постоянно шумит выше `SILENCE_THRESHOLD`, ассистент «зависнет» слушать. Решение — `silero-vad`. +- **Одна сессия = бесконечный контекст OpenClaw** пока не вызван `/new`. Нужен автотаймаут. +- **Нет fallback для gateway-offline** — при потере связи ассистент молчит до явной ошибки. diff --git a/README.md b/README.md index 1168751..451035d 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,87 @@ # Cosmo Voice Satellite -Домашний голосовой ассистент. Слушает wake word, распознаёт речь, ходит в OpenClaw gateway, проигрывает ответ через ElevenLabs. +Домашний голосовой ассистент поверх LLM — аналог Алисы/Siri, но умнее, потому что за ним стоит полноценный агент OpenClaw с памятью, tools и инструментами. -Два агента: **Cosmo** (владельца) и **Люся** (жены) — каждый со своим wake word и своим gateway. +Два агента — **Cosmo** (владельца) и **Люся** (жены). Каждый со своим wake word, своим голосом ElevenLabs и своим OpenClaw gateway. ## Архитектура ``` -mic ─► wake word (openwakeword) ─► STT (Groq) ─► OpenClaw gateway ─► TTS (ElevenLabs) ─► mpv ─► speakers +mic ─► wake word (openwakeword) + └► STT (Groq whisper) ─► OpenClaw Gateway (session_key, N100) ─► LLM + │ + ▼ streamed text + ElevenLabs TTS ─► mpv stdin ─► speakers ``` -- **Wake word:** openwakeword (обучается на своих записях, см. ниже). Раньше планировался Porcupine — отказались. -- **STT:** Groq API, `whisper-large-v3-turbo`, ru. -- **LLM:** OpenClaw gateway на N100 (`192.168.31.103:18789` для cosmo, `:18790` для lusya), `openai/gpt-5.4-mini`. -- **TTS:** ElevenLabs `eleven_flash_v2_5` стримом через mpv stdin. +- **Wake word**: openwakeword (`.onnx`, обученная на своих записях) +- **STT**: Groq API, `whisper-large-v3-turbo`, ru +- **Агент**: OpenClaw на N100, модели через `x-ocplatform-model` header (`openai/gpt-5.4-mini` и т.п.) +- **История диалога**: на сервере OpenClaw (per `session_key`), клиент stateless +- **TTS**: ElevenLabs streaming через mpv ## Структура ``` home-voice-assistant/ -├── satellite.py # entry-обёртка -├── satellite/ # рантайм -│ ├── __main__.py # python -m satellite [--wake] -│ ├── config.py, text.py -│ ├── stt.py, audio.py, tts.py, llm.py -│ └── modes.py # run_with_enter / run_with_porcupine (wake word) -├── record_wav.py # запись датасета для wake word -├── remove_silent.py # чистка тихих + перенумерация -├── training/ # пайплайн обучения wake word -│ ├── step_1.py … step_5.py -│ ├── training_config.json -│ ├── own_samples//{positive,negative}/*.wav -│ ├── openwakeword/ # форк -│ └── my_custom_model// # фичи + .onnx -├── data/models/ # готовые .onnx wake word моделей -└── deploy/ # setup.sh + systemd unit для Pi +├── satellite.py # entry-обёртка +├── satellite/ # рантайм +│ ├── __main__.py # python -m satellite [--wake] +│ ├── config.py # AGENTS, keep-alive sessions +│ ├── audio.py # запись + RMS VAD +│ ├── stt.py # Groq whisper +│ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS +│ ├── tts.py # ElevenLabs → mpv stdin +│ ├── text.py # clean_for_speech (+ pymorphy3 для времени) +│ └── modes.py # run_with_enter / run_with_porcupine +├── record_wav.py # запись датасета wake word +├── remove_silent.py # чистка тихих + перенумерация +├── training/ # openwakeword пайплайн (в .gitignore) +├── data/models/ # готовые .onnx wake word моделей +└── deploy/ # setup.sh + systemd unit для Pi 5 ``` -## Запуск +## Быстрый старт ```bash python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -cp .env.example .env # заполнить ключи +cp .env.example .env # заполнить ключи -python satellite.py # режим Enter (без wake word, для отладки) -python satellite.py --wake # режим wake word (нужна обученная модель в data/models/) +python satellite.py # режим Enter (отладка) +python satellite.py --wake # режим wake word (нужна модель в data/models/) ``` -Системные зависимости: -- Python 3.12+ -- `portaudio` — `brew install portaudio` -- `mpv` — `brew install mpv` +### Системные зависимости + +macOS: `brew install portaudio mpv` +Linux: `apt install portaudio19-dev mpv` +Windows: мpv с [mpv.io](https://mpv.io), `pip install pipwin && pipwin install pyaudio` + +## Сессия диалога + +История и память живут **на стороне OpenClaw**. Satellite отправляет только текущее сообщение + `x-openclaw-session-key`. Сервер сам подклеивает контекст. + +Сброс сессии: +- Голосом: «начни новую сессию» / «сбрось историю» / «очисти контекст» → satellite шлёт slash-команду `/new` в OpenClaw +- Программно: меняй `COSMO_SESSION_KEY` в `.env` ## Обучение своего wake word -OpenWakeWord обучает DNN-модель на твоих записях слова. Пайплайн в `training/`: +OpenWakeWord тренирует DNN-модель на твоих записях слова. Пайплайн в `training/`: | Шаг | Что делает | |-----|-----------| | `step_1.py` | Установка зависимостей (piper, openwakeword) | -| `step_2.py` | Создаёт `training_config.json` (параметры обучения) | -| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features) — ~17 GB | +| `step_2.py` | Создаёт `training_config.json` | +| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features, ~17 GB) | | `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` | | `step_5.py` | Проверка моделей и подсказки для `.env` | ### Запись датасета ```bash -# по одной записи (Enter → 2 секунды → сохраняем) +# по одной записи (Enter → 2 с → сохраняем) python record_wav.py cosmo positive python record_wav.py cosmo negative @@ -77,7 +90,7 @@ python record_wav.py cosmo negative long 300 INPUT_DEVICE=1 python record_wav.py cosmo negative long 600 ``` -`record_wav.py` отбраковывает тихие записи по `MIN_RMS=300` (изменить можно константой в начале файла). +`record_wav.py` отбраковывает тихие записи по `MIN_RMS=300`. ### Чистка @@ -89,85 +102,126 @@ python remove_silent.py ### Тренировка -1. В `training/training_config.json` укажи `wake_word_list`, `use_own_samples: true`, параметры: - ```json - { - "wake_word_list": ["cosmo"], - "use_own_samples": true, - "false_activation_penalty": 100, - "target_false_positives_per_hour": 3.0, - "target_recall": 0.5, - "number_of_training_steps": 3000, - "layer_size": 64 - } - ``` -2. В `training/openwakeword/examples/custom_model.yml` подними `augmentation_rounds: 10` (или больше). -3. Снеси кэш если был старый запуск: - ```bash - rm -rf training/my_custom_model/ data/models/.onnx - ``` -4. Запусти: - ```bash - python training/step_4.py - ``` -5. Пропиши в `.env`: `WAKE_WORD_COSMO=data/models/cosmo.onnx`. +```bash +# в training/training_config.json: +# { +# "wake_word_list": ["cosmo"], +# "use_own_samples": true, +# "false_activation_penalty": 100, +# "target_false_positives_per_hour": 3.0, +# "target_recall": 0.5, +# "number_of_training_steps": 3000, +# "layer_size": 64 +# } + +rm -rf training/my_custom_model/cosmo data/models/cosmo.onnx +python training/step_4.py +# → .env: WAKE_WORD_COSMO=data/models/cosmo.onnx +``` ### Сколько данных нужно -| Positive | Negative | Recall | +| Positive | Negative | Ожидаемый recall | |---|---|---| | 100–200 | 200+ | 0.1–0.3 (плохо) | -| 300–500 | 500+ | 0.4–0.6 (минимум) | +| 300–500 | 500+ | 0.4–0.6 (минимум для работы) | | 800–1500 | 1000+ | 0.7–0.85 | | 2000+ | 2000+ | 0.9+ | -Главное — **разнообразие**: разные дистанции до микрофона, интонации, время дня, фоны. Аугментация (`augmentation_rounds`) умножит твой датасет в N раз во время обучения. - -Негативы должны включать **фонетически близкие** слова ("космос", "косо", "просто"), обычную речь, имена других ассистентов ("алиса", "сири"), бытовые звуки. - -## Архитектурные решения - -- **Одна сессия диалога на день** на агента (`Conversation` в `llm.py`). История хранится клиентом, отправляется целиком. Сброс — фразой "сбрось историю" или сменой даты. -- **Keep-alive HTTP** (`requests.Session`) — переиспользует TCP/TLS. -- **Streaming TTS** — ElevenLabs пайпится в `mpv` через stdin, играет пока генерируется. -- **STT без диска** — PCM → WAV в `BytesIO` → Groq. -- **Barge-in** — `stop_speaking()` убивает mpv при новой активации. -- **Ошибки не роняют сервис** — каждый слой ловит `Exception`, пишет в `errors.log`. +Главное — **разнообразие**: разные дистанции, интонации, время дня, фоны. Негативы должны включать фонетически близкие слова («космос», «косо», «просто»), обычную речь, имена других ассистентов («Алиса», «Сири»). ## .env (ключевые переменные) | Переменная | Что | |---|---| -| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | OpenClaw gateways | -| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Авторизация | -| `AGENT`, `LUSYA_AGENT` | `openclaw/main`, `openclaw/wife` | -| `VOICE_MODEL` | LLM для голоса (передаётся в `x-openclaw-model`) | +| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway | +| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены | +| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw | +| `VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) | +| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии | | `GROQ_API_KEY` | STT | -| `ELEVENLABS_API_KEY`, `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | TTS | -| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям | -| `SILENCE_THRESHOLD`, `SILENCE_DURATION` | VAD | -| `MAX_HISTORY` | Лимит сообщений в сессии | -| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink` | +| `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL` | TTS | +| `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID | +| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` | +| `WAKE_THRESHOLD` | Порог активации (дефолт 0.5) | +| `TTS_MODE` | `full` (цельная интонация) / `stream` (быстрый старт) | +| `AUDIO_SINK` | На Pi: BT sink. На Mac/Win: пусто | +| `SILENCE_THRESHOLD`, `SILENCE_DURATION`, `MAX_DURATION`, `FOLLOWUP_TIMEOUT` | VAD | -## Деплой на Raspberry Pi +## Деплой на Raspberry Pi 5 ```bash sudo bash deploy/setup.sh +# подключи BT колонку, пропиши AUDIO_SINK в .env +# положи обученную .onnx в data/models/cosmo.onnx sudo systemctl start cosmo-satellite sudo journalctl -u cosmo-satellite -f ``` ## Roadmap -- [x] Модулизация satellite -- [x] ElevenLabs streaming + barge-in -- [x] Сессии диалога с автосбросом -- [x] Пайплайн тренировки wake word на своих записях -- [ ] Обучить рабочую модель cosmo (нужно ~500+ позитивов) -- [ ] Подключить Люсю в `run_with_porcupine` (сейчас грузится только cosmo) -- [ ] Проверить systemd autostart на Pi в проде -- [ ] Home Assistant tool в OpenClaw -- [ ] Real-time barge-in (прерывание голосом во время TTS) -- [ ] Контекст окружения в system prompt -- [ ] Speaker identification -- [ ] Проактивные уведомления через WebSocket +### ✅ Сделано + +- Модульная структура satellite +- ElevenLabs streaming TTS через mpv pipe +- Keep-alive HTTP + STT без диска +- Серверные сессии OpenClaw (`x-openclaw-session-key`) +- Slash-команда `/new` для сброса голосом +- Нормализация речи (числа, время, единицы) через pymorphy3 + num2words +- Пайплайн тренировки wake word на своих записях +- systemd unit для Pi + +### 🚧 В работе + +- Дообучить wake-модель до recall ≥ 0.7 (нужно 500+ позитивов + разнообразие) +- Подключить Люсю в `run_with_porcupine` (код готов, закомментирован) +- Чистка мёртвого кода (`start_barge_in_listener`, `conv` и т.п.) +- Починить `FOLLOWUP_TIMEOUT` (сейчас после ответа ассистент ждёт полный 15 с) + +### 📋 Этап 2 — качество и надёжность + +- Автосброс OpenClaw сессии по таймауту (>1 ч → `/new`) +- Retry с backoff для gateway +- TTS-cache дежурных реплик +- Persistent PyAudio stream (быстрее запись на Pi) +- Заменить RMS-VAD на `webrtcvad` / `silero-vad` — RMS ломается с фоновой музыкой +- Whisper `prompt` с именами собственными +- Size-cap / logrotate для `errors.log` + +### 📋 Этап 3 — «умнее Алисы» + +- **Home Assistant tool** в OpenClaw: свет/климат/медиа голосом +- **Контекст окружения** в каждом запросе: время, комната, погода, кто говорит +- **Proactive notifications**: OpenClaw → WebSocket/SSE → satellite сам начинает говорить (таймеры, напоминания, входящие) +- **Realtime barge-in голосом** — прерывать TTS, когда пользователь начал говорить (требует echo cancellation) +- **No-wake mode** в доверенной комнате — VAD + STT + intent filter без обязательного wake word +- **Streaming TTS пер-токен** — выдавать в речь куски раньше полного предложения + +### 📋 Этап 4 — амбициозное + +- **Speaker identification** (`pyannote.audio` / `resemblyzer`) — разные персонализации по голосу +- **Multi-room координация** — MQTT между сателлитами, отвечает тот, кто слышит громче +- **Локальный fallback LLM** на Pi когда gateway оффлайн (phi/llama для простых команд) +- **Камера + vision** — агент видит кто в комнате, что происходит +- **Voice-memory hooks UX** — голосовое «запомни/забудь» + +## Известные ограничения + +- Нет echo cancellation — если колонки близко к мику, TTS может триггерить wake-модель (поднимай `WAKE_THRESHOLD`). +- RMS-VAD не отличает голос от музыки/ТВ — ассистент может «залипнуть» в шумной среде. +- OpenClaw-сессия живёт вечно, пока не сказать «сбрось» — контекст раздувается. +- При потере связи с gateway — ассистент молчит до явной ошибки. + +## Troubleshooting + +**Ассистент «иностранец», монотонно читает** → смени `ELEVENLABS_MODEL` на `eleven_multilingual_v2`, возьми native-русский голос из Voice Library (не default English-speakers), в `tts.py` поставь `style=0.0, speed=1.0` (style/speed ломают просодию). + +**Числа читаются цифрами вместо слов** → проверь `text.py::clean_for_speech` применяется. Для времени нужны `num2words` и `pymorphy3` в requirements. + +**Wake срабатывает сам по себе, когда TTS говорит** → `WAKE_THRESHOLD=0.7`, разнеси колонку и мик. В будущем — AEC. + +**Ассистент не реагирует на тихий голос** → понижай `SILENCE_THRESHOLD` (дефолт 500). + +**`pyaudio` не ставится на Windows** → `pip install pipwin && pipwin install pyaudio`, или бери pre-built wheel. + +**Wake модель плохо работает** → дело в данных. Смотри таблицу выше — нужно минимум 500 позитивов с разнообразием. diff --git a/requirements.txt b/requirements.txt index 454ffb2..13b229c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ numpy<2 pyaudio sounddevice scipy<1.15 +webrtcvad-wheels # STT через облако groq diff --git a/satellite/audio.py b/satellite/audio.py index 40f2411..b0ac973 100644 --- a/satellite/audio.py +++ b/satellite/audio.py @@ -2,22 +2,59 @@ import os import pyaudio import numpy as np -from .config import SILENCE_THRESHOLD, SILENCE_DURATION, MAX_DURATION, log +from .config import ( + SILENCE_THRESHOLD, SILENCE_DURATION, MAX_DURATION, + FOLLOWUP_TIMEOUT, VAD_AGGRESSIVENESS, log, +) from .stt import transcribe ECHO_WARMUP = float(os.getenv("ECHO_WARMUP", "0.5")) # сек пропуска в начале — гасит эхо от TTS +try: + import webrtcvad + _vad = webrtcvad.Vad(VAD_AGGRESSIVENESS) + _VAD_OK = True +except Exception as e: + log.warning(f"webrtcvad недоступен, fallback на RMS: {e}") + _vad = None + _VAD_OK = False + +# webrtcvad требует фрейм 10/20/30 мс при 8/16/32/48 кГц +SAMPLE_RATE = 16000 +FRAME_MS = 30 +FRAME_SAMPLES = int(SAMPLE_RATE * FRAME_MS / 1000) # 480 +FRAME_BYTES = FRAME_SAMPLES * 2 # int16 + + +def _is_speech(frame: bytes) -> bool: + """Единое решение по VAD: webrtcvad + RMS-гейт, чтобы не ловить шёпот и эхо.""" + amplitude = float(np.abs(np.frombuffer(frame, dtype=np.int16)).mean()) + if amplitude < SILENCE_THRESHOLD: + return False + if _VAD_OK: + try: + return _vad.is_speech(frame, SAMPLE_RATE) + except Exception: + pass + return True # RMS уже прошёл — считаем речью + + +def record(initial_silence_timeout: float | None = None) -> str: + """Запись до тишины + STT. + initial_silence_timeout — через сколько секунд выйти если пользователь вообще не начал говорить. + По умолчанию FOLLOWUP_TIMEOUT (короткое ожидание после ответа бота). + """ + if initial_silence_timeout is None: + initial_silence_timeout = FOLLOWUP_TIMEOUT -def record() -> str: - """Запись до тишины (VAD) + STT. Игнорирует ECHO_WARMUP в начале.""" try: audio = pyaudio.PyAudio() stream = audio.open( format=pyaudio.paInt16, channels=1, - rate=16000, + rate=SAMPLE_RATE, input=True, - frames_per_buffer=1024, + frames_per_buffer=FRAME_SAMPLES, ) except Exception as e: log.exception("Не удалось открыть микрофон") @@ -25,30 +62,38 @@ def record() -> str: return "" print("🎙️ Говори...") - frames = [] - silent_chunks = 0 + frames: list[bytes] = [] speaking_started = False - max_chunks = int(16000 / 1024 * MAX_DURATION) - silence_chunks_needed = int(16000 / 1024 * SILENCE_DURATION) - warmup_chunks = int(16000 / 1024 * ECHO_WARMUP) + trailing_silence = 0 # фреймы тишины после начала речи + initial_silence = 0 # фреймы тишины до начала речи + + max_frames = int(MAX_DURATION * 1000 / FRAME_MS) + warmup_frames = int(ECHO_WARMUP * 1000 / FRAME_MS) + silence_frames_needed = int(SILENCE_DURATION * 1000 / FRAME_MS) + initial_silence_limit = int(initial_silence_timeout * 1000 / FRAME_MS) try: - for i in range(max_chunks): - data = stream.read(1024, exception_on_overflow=False) - if i < warmup_chunks: - continue # гасим эхо от TTS / звука активации + for i in range(max_frames): + data = stream.read(FRAME_SAMPLES, exception_on_overflow=False) + if i < warmup_frames: + continue frames.append(data) - amplitude = np.abs(np.frombuffer(data, dtype=np.int16)).mean() - - if amplitude > SILENCE_THRESHOLD: + if _is_speech(data): speaking_started = True - silent_chunks = 0 - elif speaking_started: - silent_chunks += 1 - if silent_chunks >= silence_chunks_needed: - print("🔇 Конец речи") - break + trailing_silence = 0 + else: + if speaking_started: + trailing_silence += 1 + if trailing_silence >= silence_frames_needed: + print("🔇 Конец речи") + break + else: + initial_silence += 1 + if initial_silence >= initial_silence_limit: + print("😴 Пользователь молчит, выхожу") + speaking_started = False + break except Exception as e: log.exception("Ошибка при записи аудио") print(f"⚠️ Ошибка записи: {e}") diff --git a/satellite/config.py b/satellite/config.py index 1ef6d17..5de4b53 100644 --- a/satellite/config.py +++ b/satellite/config.py @@ -20,15 +20,14 @@ logging.basicConfig( log = logging.getLogger("cosmo") # OpenClaw Gateway — Cosmo (по умолчанию) +# Роутинг к нужному агенту делается через x-openclaw-session-key, поэтому AGENT не нужен. GATEWAY_URL = os.getenv("GATEWAY_URL", "http://192.168.31.103:18789") GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN") -AGENT = os.getenv("AGENT", "openclaw/main") VOICE_MODEL = os.getenv("VOICE_MODEL", "openai/gpt-4o-mini") # OpenClaw Gateway — Люся LUSYA_GATEWAY_URL = os.getenv("LUSYA_GATEWAY_URL", "http://192.168.31.103:18790") LUSYA_GATEWAY_TOKEN = os.getenv("LUSYA_GATEWAY_TOKEN", GATEWAY_TOKEN) -LUSYA_AGENT = os.getenv("LUSYA_AGENT", "openclaw/wife") LUSYA_VOICE_MODEL = os.getenv("LUSYA_VOICE_MODEL", VOICE_MODEL) # Keep-alive HTTP сессии — переиспользуют TCP/TLS соединения @@ -46,20 +45,16 @@ AGENTS = { "cosmo": { "name": "Cosmo", "gateway_url": GATEWAY_URL, - "token": GATEWAY_TOKEN, - "agent": AGENT, "voice_model": VOICE_MODEL, - "session_key": os.getenv("COSMO_SESSION_KEY", "voice:home:cosmo"), + "session_key": os.getenv("COSMO_SESSION_KEY", "agent:main:voice:home"), "tts_voice": os.getenv("COSMO_TTS_VOICE", ""), "session": _make_session(GATEWAY_TOKEN), }, "lusya": { "name": "Люся", "gateway_url": LUSYA_GATEWAY_URL, - "token": LUSYA_GATEWAY_TOKEN, - "agent": LUSYA_AGENT, "voice_model": LUSYA_VOICE_MODEL, - "session_key": os.getenv("LUSYA_SESSION_KEY", "voice:home:lusya"), + "session_key": os.getenv("LUSYA_SESSION_KEY", "agent:wife:voice:home"), "tts_voice": os.getenv("LUSYA_TTS_VOICE", ""), "session": _make_session(LUSYA_GATEWAY_TOKEN), }, @@ -73,6 +68,18 @@ SILENCE_THRESHOLD = int(os.getenv("SILENCE_THRESHOLD", "500")) SILENCE_DURATION = float(os.getenv("SILENCE_DURATION", "1.5")) MAX_DURATION = int(os.getenv("MAX_DURATION", "15")) FOLLOWUP_TIMEOUT = float(os.getenv("FOLLOWUP_TIMEOUT", "8")) +VAD_AGGRESSIVENESS = int(os.getenv("VAD_AGGRESSIVENESS", "2")) # webrtcvad 0..3 + +# LLM +VOICE_MAX_TOKENS = int(os.getenv("VOICE_MAX_TOKENS", "300")) +LLM_RETRIES = int(os.getenv("LLM_RETRIES", "3")) + +# Barge-in (прерывание TTS голосом) +# Работает только при разнесённых колонке/мике или в наушниках — иначе эхо собственного TTS +# будет триггерить прерывание. По умолчанию выключен. +BARGE_IN_ENABLED = os.getenv("BARGE_IN_ENABLED", "false").lower() in ("1", "true", "yes") +BARGE_IN_THRESHOLD = int(os.getenv("BARGE_IN_THRESHOLD", "1500")) # RMS, обычно > SILENCE_THRESHOLD +BARGE_IN_WARMUP = float(os.getenv("BARGE_IN_WARMUP", "0.8")) # сек пропуска в начале TTS # Groq client groq_client = Groq(api_key=os.getenv("GROQ_API_KEY")) diff --git a/satellite/llm.py b/satellite/llm.py index 7b07824..796c4fb 100644 --- a/satellite/llm.py +++ b/satellite/llm.py @@ -1,13 +1,13 @@ import json import os import re +import time import requests -from .config import AGENTS, log +from .config import AGENTS, VOICE_MAX_TOKENS, LLM_RETRIES, log from .text import clean_for_speech, find_sentence_end from .tts import speak, play_error_sound -# Ключ голосовой сессии — Cosmo работает как полноценный агент VOICE_SESSION_KEY = os.getenv("VOICE_SESSION_KEY", "agent:main:voice:home") # "stream" — режем по предложениям (быстро, но рваная интонация) @@ -26,67 +26,86 @@ FILLER_PATTERNS = re.compile( r'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю' r'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник' r'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\s*)+', - re.IGNORECASE + re.IGNORECASE, ) + def strip_fillers(text: str) -> str: return FILLER_PATTERNS.sub('', text).strip() - - def is_reset_command(text: str) -> bool: return bool(RESET_PATTERNS.search(text)) -def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str: +def _post_with_retry(session, url, headers, payload): + """POST с экспоненциальным backoff. Retry на сетевые ошибки и 5xx; 4xx — сразу вверх.""" + last_exc = None + for attempt in range(LLM_RETRIES): + try: + resp = session.post(url, headers=headers, json=payload, stream=True, timeout=60) + if resp.status_code >= 500: + raise requests.HTTPError(f"{resp.status_code} {resp.text[:200]}", response=resp) + resp.raise_for_status() + return resp + except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as e: + last_exc = e + # 4xx (кроме 408/429) не ретраим + resp = getattr(e, "response", None) + if isinstance(e, requests.HTTPError) and resp is not None: + if resp.status_code < 500 and resp.status_code not in (408, 429): + raise + if attempt == LLM_RETRIES - 1: + raise + delay = 0.5 * (2 ** attempt) + log.warning(f"Gateway retry {attempt + 1}/{LLM_RETRIES} через {delay:.1f}s: {e}") + time.sleep(delay) + raise last_exc # unreachable + + +def ask_agent_stream(text: str, agent_id: str = "cosmo") -> str: """Отправляет запрос к OpenClaw gateway и озвучивает ответ.""" def _maybe_speak(t: str): if t.strip(): speak(t, agent_id) cfg = AGENTS.get(agent_id, AGENTS["cosmo"]) - gateway_url = cfg["gateway_url"] - session = cfg["session"] - agent = cfg["agent"] - session_key = cfg.get("session_key", VOICE_SESSION_KEY) + payload = { + "stream": True, + "messages": [{"role": "user", "content": text}], + "max_tokens": VOICE_MAX_TOKENS, + } + headers = { + "x-ocplatform-model": cfg["voice_model"], + "x-openclaw-session-key": session_key, + } + try: - resp = session.post( - f"{gateway_url}/v1/chat/completions", - headers={ - "x-ocplatform-model": cfg["voice_model"], - "x-openclaw-session-key": session_key, - }, - json={ - "model": agent, - "stream": True, - "messages": [{"role": "user", "content": text}], - "max_tokens": 150, - }, - stream=True, - timeout=60, + resp = _post_with_retry( + cfg["session"], f"{cfg['gateway_url']}/v1/chat/completions", headers, payload, ) - resp.raise_for_status() except requests.ConnectionError: - log.exception("Gateway недоступен") + log.exception("Gateway недоступен после retry") msg = "Не могу связаться с сервером, попробуй ещё раз." print(f"⚠️ {msg}") play_error_sound() _maybe_speak(msg) return msg except requests.Timeout: - log.exception("Gateway таймаут") + log.exception("Gateway таймаут после retry") msg = "Сервер не ответил вовремя, попробуй ещё раз." print(f"⚠️ {msg}") play_error_sound() _maybe_speak(msg) return msg - except requests.HTTPError: - log.exception(f"Gateway HTTP ошибка {resp.status_code}") + except requests.HTTPError as e: + status = e.response.status_code if e.response is not None else "?" + body = e.response.text if e.response is not None else "" + log.exception(f"Gateway HTTP {status}") msg = "Ошибка сервера, попробуй ещё раз." - print(f"⚠️ Gateway {resp.status_code}: {resp.text}") + print(f"⚠️ Gateway {status}: {body[:200]}") play_error_sound() _maybe_speak(msg) return msg @@ -98,25 +117,25 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str: for line in resp.iter_lines(): if not line or line == b"data: [DONE]": continue - if line.startswith(b"data: "): - try: - chunk = json.loads(line[6:]) - delta = chunk["choices"][0]["delta"].get("content", "") - if not delta: - continue - - full_text += delta - buffer += delta - - if TTS_MODE == "stream": - last_punct = find_sentence_end(buffer, min_len=120) - if last_punct > -1: - sentence = clean_for_speech(buffer[:last_punct + 1]) - _maybe_speak(sentence) - buffer = buffer[last_punct + 1:].lstrip() - - except (json.JSONDecodeError, KeyError, IndexError): + if not line.startswith(b"data: "): + continue + try: + chunk = json.loads(line[6:]) + delta = chunk["choices"][0]["delta"].get("content", "") + if not delta: continue + + full_text += delta + buffer += delta + + if TTS_MODE == "stream": + last_punct = find_sentence_end(buffer, min_len=120) + if last_punct > -1: + sentence = clean_for_speech(strip_fillers(buffer[:last_punct + 1])) + _maybe_speak(sentence) + buffer = buffer[last_punct + 1:].lstrip() + except (json.JSONDecodeError, KeyError, IndexError): + continue except Exception as e: log.exception("Ошибка при чтении стрима") print(f"⚠️ Стрим прервался: {e}") @@ -132,6 +151,6 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str: _maybe_speak(result) else: if buffer.strip(): - _maybe_speak(clean_for_speech(buffer)) + _maybe_speak(clean_for_speech(strip_fillers(buffer))) return result diff --git a/satellite/modes.py b/satellite/modes.py index a2163c9..90a9906 100644 --- a/satellite/modes.py +++ b/satellite/modes.py @@ -1,9 +1,8 @@ import os -import sys -from .config import GATEWAY_URL, AGENT, AGENTS, log +from .config import GATEWAY_URL, AGENTS, FOLLOWUP_TIMEOUT, MAX_DURATION, log from .audio import record -from .tts import speak, stop_speaking, is_speaking, start_barge_in_listener, was_barge_in +from .tts import speak, stop_speaking from .llm import ask_agent_stream, is_reset_command, VOICE_SESSION_KEY WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5")) @@ -24,7 +23,6 @@ def _handle_reset(text: str, agent_id: str) -> bool: "x-openclaw-session-key": cfg.get("session_key", VOICE_SESSION_KEY), }, json={ - "model": cfg["agent"], "stream": False, "messages": [{"role": "user", "content": "/new"}], }, @@ -40,11 +38,15 @@ def _handle_reset(text: str, agent_id: str) -> bool: def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"): - """Основной цикл диалога — слушает и отвечает пока пользователь говорит.""" + """Основной цикл диалога. + Первая запись — с большим таймаутом (MAX_DURATION), дальше — короткий FOLLOWUP_TIMEOUT.""" + first = True while True: - text = record() + timeout = MAX_DURATION if first else FOLLOWUP_TIMEOUT + first = False + text = record(initial_silence_timeout=timeout) if not text: - print(f"😴 Тишина, жду активации...\n") + print("😴 Тишина, жду активации...\n") return print(f"📝 Ты → {agent_name}: {text}") @@ -59,7 +61,6 @@ def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"): def run_with_enter(): print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)") print(f" Gateway : {GATEWAY_URL}") - print(f" Агент : {AGENT}") print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n") while True: @@ -97,7 +98,6 @@ def run_with_porcupine(): input=True, frames_per_buffer=1280) print("✅ Слушаю через OpenWakeWord...") - # print("\nСкажи 'Космо' или 'Люся'...\n") # TODO: после подключения Люси try: while True: @@ -110,12 +110,7 @@ def run_with_porcupine(): print(f"PREDICTION cosmo: {cosmo_score:.3f}") if cosmo_score > WAKE_THRESHOLD: - if is_speaking(): - # Barge-in: прерываем TTS - print("✋ Barge-in: прерываю ответ") - stop_speaking() - cosmo_model.reset() - continue + stop_speaking() # на случай если TTS ещё играет stream.stop_stream() _conversation_loop("cosmo", "Cosmo") cosmo_model.reset() @@ -124,10 +119,8 @@ def run_with_porcupine(): # TODO: Люся — раскомментировать когда модель готова # lusya_score = lusya_model.predict(pcm)["lusya"] - # if lusya_score > 0.1: - # print(f"PREDICTION lusya: {lusya_score:.3f}") - # if lusya_score > 0.5: - # print("✅ Услышала 'Люся'!") + # if lusya_score > WAKE_THRESHOLD: + # stop_speaking() # stream.stop_stream() # _conversation_loop("lusya", "Люся") # lusya_model.reset() diff --git a/satellite/tts.py b/satellite/tts.py index 1463112..dca4617 100644 --- a/satellite/tts.py +++ b/satellite/tts.py @@ -1,10 +1,12 @@ import os -import sys import subprocess import threading from elevenlabs import VoiceSettings -from .config import AUDIO_SINK, AGENTS, SILENCE_THRESHOLD, log +from .config import ( + AUDIO_SINK, AGENTS, log, + BARGE_IN_ENABLED, BARGE_IN_THRESHOLD, BARGE_IN_WARMUP, +) ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "") ELEVENLABS_MODEL = os.getenv("ELEVENLABS_MODEL", "eleven_flash_v2_5") @@ -40,45 +42,7 @@ def is_speaking() -> bool: return _current_process is not None and _current_process.poll() is None -_barge_in_flag = threading.Event() - -def start_barge_in_listener(): - """Запускает фоновый поток VAD — если услышал голос во время TTS, ставит флаг barge-in.""" - _barge_in_flag.clear() - - def _listen(): - import pyaudio - import numpy as np - try: - audio = pyaudio.PyAudio() - stream = audio.open(format=pyaudio.paInt16, channels=1, rate=16000, - input=True, frames_per_buffer=1024) - warmup = 8 # ~0.5s прогрев чтобы не словить эхо начала TTS - i = 0 - while is_speaking(): - data = stream.read(1024, exception_on_overflow=False) - i += 1 - if i < warmup: - continue - amplitude = np.abs(np.frombuffer(data, dtype=np.int16)).mean() - if amplitude > SILENCE_THRESHOLD * 1.5: # порог чуть выше чем для записи - _barge_in_flag.set() - stop_speaking() - break - stream.stop_stream() - audio.terminate() - except Exception: - pass - - t = threading.Thread(target=_listen, daemon=True) - t.start() - return t - -def was_barge_in() -> bool: - return _barge_in_flag.is_set() - def _mpv_cmd() -> list[str]: - """Команда mpv для воспроизведения из stdin""" mpv_bin = os.getenv("MPV_PATH", "mpv") cmd = [mpv_bin, "--no-video", "--really-quiet", "--no-terminal"] if AUDIO_SINK: @@ -87,13 +51,19 @@ def _mpv_cmd() -> list[str]: return cmd -def speak(text: str, agent_id: str = "cosmo"): +def speak(text: str, agent_id: str = "cosmo") -> bool: + """Озвучивает text. Если BARGE_IN_ENABLED — слушает мик и может прерваться. + Возвращает True если был прерван голосом.""" try: + if BARGE_IN_ENABLED: + return _speak_with_barge_in(text, agent_id) _speak_elevenlabs(text, agent_id) + return False except Exception as e: log.exception("TTS ошибка") print(f"⚠️ Ошибка воспроизведения: {e}") play_error_sound() + return False def _speak_elevenlabs(text: str, agent_id: str): @@ -107,11 +77,11 @@ def _speak_elevenlabs(text: str, agent_id: str): return voice_settings = VoiceSettings( - stability=0.4, # ниже = живее интонация (для multilingual_v2) + stability=0.4, similarity_boost=0.8, - style=0.1, # выше = эмоциональнее + style=0.1, use_speaker_boost=True, - speed=1.1 + speed=1.1, ) audio_stream = client.text_to_speech.convert( @@ -120,7 +90,7 @@ def _speak_elevenlabs(text: str, agent_id: str): model_id=ELEVENLABS_MODEL, output_format="mp3_22050_32", voice_settings=voice_settings, - optimize_streaming_latency=3 + optimize_streaming_latency=3, ) with _process_lock: @@ -148,9 +118,74 @@ def _speak_elevenlabs(text: str, agent_id: str): _current_process = None +def _speak_with_barge_in(text: str, agent_id: str) -> bool: + """Запускает TTS в фоновом потоке и параллельно слушает мик через VAD. + Если обнаружена сильная речь — прерывает TTS. Возвращает True если прервали.""" + t = threading.Thread(target=_speak_elevenlabs, args=(text, agent_id), daemon=True) + t.start() + interrupted = _listen_for_barge_in(lambda: t.is_alive()) + t.join() + return interrupted + + +def _listen_for_barge_in(still_alive) -> bool: + """Ждёт речь на входе пока still_alive() == True. Возвращает True если прервал.""" + import pyaudio + import numpy as np + try: + import webrtcvad + vad = webrtcvad.Vad(3) # максимум агрессивности — меньше ложных на эхо + except Exception: + vad = None + + SR = 16000 + FRAME_MS = 30 + FRAME_SAMPLES = int(SR * FRAME_MS / 1000) + warmup_frames = int(BARGE_IN_WARMUP * 1000 / FRAME_MS) + required_speech_frames = 8 # ~240 мс подряд + + try: + audio = pyaudio.PyAudio() + stream = audio.open(format=pyaudio.paInt16, channels=1, rate=SR, + input=True, frames_per_buffer=FRAME_SAMPLES) + except Exception as e: + log.warning(f"Barge-in: не открылся мик: {e}") + return False + + interrupted = False + speech_streak = 0 + i = 0 + try: + while still_alive(): + data = stream.read(FRAME_SAMPLES, exception_on_overflow=False) + i += 1 + if i < warmup_frames: + continue + amplitude = float(np.abs(np.frombuffer(data, dtype=np.int16)).mean()) + if amplitude < BARGE_IN_THRESHOLD: + speech_streak = 0 + continue + if vad is None or vad.is_speech(data, SR): + speech_streak += 1 + if speech_streak >= required_speech_frames: + print(f"✋ Barge-in: слышу речь ({amplitude:.0f}), прерываю TTS") + stop_speaking() + interrupted = True + break + else: + speech_streak = 0 + except Exception: + log.exception("Barge-in ошибка") + finally: + try: + stream.stop_stream() + audio.terminate() + except Exception: + pass + return interrupted + + def _play_sound_file(filename: str, wait: bool = False): - """Воспроизводит файл из папки sounds/ через mpv. - wait=True — блокирует до конца воспроизведения.""" sounds_dir = os.path.join(os.path.dirname(__file__), "..", "sounds") path = os.path.normpath(os.path.join(sounds_dir, filename)) mpv_bin = os.getenv("MPV_PATH", "mpv") @@ -162,7 +197,6 @@ def _play_sound_file(filename: str, wait: bool = False): def play_activation_sound(): - """Звук активации — неблокирующий""" try: _play_sound_file("Success_Cosmo.mp3", wait=False) except Exception as e: @@ -170,7 +204,6 @@ def play_activation_sound(): def play_error_sound(): - """Звук ошибки — 'не получилось'""" try: _play_sound_file("Error_Cosmo.mp3") except Exception as e: