refactor: VAD upgrade, retry, dead code cleanup, AGENT removal
- audio: switch VAD to webrtcvad with RMS gate + fallback to RMS - audio: honor FOLLOWUP_TIMEOUT — short silence wait after bot response - llm: retry with exponential backoff on network errors and 5xx - llm: VOICE_MAX_TOKENS env (default 300) instead of hardcoded 150 - tts: optional VAD-based barge-in (BARGE_IN_ENABLED, off by default) - tts: remove dead start_barge_in_listener / was_barge_in helpers - config: drop AGENT/LUSYA_AGENT — routing happens via session_key - modes: remove unused imports, pass FOLLOWUP_TIMEOUT to follow-up record() - docs: full rewrite of README and CLAUDE.md to match current architecture
This commit is contained in:
14
.env.example
14
.env.example
@@ -1,13 +1,12 @@
|
|||||||
# OpenClaw Gateway — Cosmo
|
# OpenClaw Gateway — Cosmo
|
||||||
|
# Роутинг к агенту идёт через COSMO_SESSION_KEY, отдельный AGENT не нужен.
|
||||||
GATEWAY_URL=http://192.168.31.103:18789
|
GATEWAY_URL=http://192.168.31.103:18789
|
||||||
GATEWAY_TOKEN=your_openclaw_token_here
|
GATEWAY_TOKEN=your_openclaw_token_here
|
||||||
AGENT=openclaw/main
|
|
||||||
VOICE_MODEL=openai/gpt-5.4-mini
|
VOICE_MODEL=openai/gpt-5.4-mini
|
||||||
|
|
||||||
# OpenClaw Gateway — Люся
|
# OpenClaw Gateway — Люся
|
||||||
LUSYA_GATEWAY_URL=http://192.168.31.103:18790
|
LUSYA_GATEWAY_URL=http://192.168.31.103:18790
|
||||||
LUSYA_GATEWAY_TOKEN=your_openclaw_token_here
|
LUSYA_GATEWAY_TOKEN=your_openclaw_token_here
|
||||||
LUSYA_AGENT=openclaw/main
|
|
||||||
LUSYA_VOICE_MODEL=openai/gpt-5.4-mini
|
LUSYA_VOICE_MODEL=openai/gpt-5.4-mini
|
||||||
|
|
||||||
# STT (Groq)
|
# STT (Groq)
|
||||||
@@ -31,6 +30,17 @@ SILENCE_THRESHOLD=500
|
|||||||
SILENCE_DURATION=1.5
|
SILENCE_DURATION=1.5
|
||||||
MAX_DURATION=15
|
MAX_DURATION=15
|
||||||
FOLLOWUP_TIMEOUT=8
|
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
|
LOG_FILE=errors.log
|
||||||
|
|||||||
249
CLAUDE.md
249
CLAUDE.md
@@ -1,85 +1,83 @@
|
|||||||
# Cosmo Voice Satellite
|
# Cosmo Voice Satellite
|
||||||
|
|
||||||
Голосовой ассистент дома — аналог Алисы через OpenClaw. Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway.
|
Голосовой ассистент дома — аналог Алисы, но поверх LLM (через OpenClaw Gateway). Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ wake word ┌──────────────┐ STT (Groq)
|
┌─────────────┐ wake word ┌──────────────┐ STT (Groq)
|
||||||
│ Microphone │ ───────────► │ Satellite │ ──────────────►
|
│ Microphone │ ────────────► │ Satellite │ ──────────────► OpenClaw Gateway
|
||||||
└─────────────┘ └──────────────┘ │
|
└─────────────┘ │ (Pi 5 / │ (N100, Proxmox)
|
||||||
│ ▼
|
│ Mac) │ ◄── LLM stream ──
|
||||||
│ ┌──────────────┐
|
└──────────────┘ │
|
||||||
│ │ OpenClaw │
|
│ │
|
||||||
│ │ Gateway │
|
▼ TTS текст │
|
||||||
│ │ (N100 PC) │
|
ElevenLabs stream (mp3) │
|
||||||
│ stream response └──────────────┘
|
│ │
|
||||||
▼ │
|
▼ │
|
||||||
┌──────────────┐ │
|
mpv (stdin) → speakers (BT/aux) ◄──┘
|
||||||
│ ElevenLabs │ ◄─────────────────┘
|
|
||||||
│ TTS │
|
|
||||||
└──────────────┘
|
|
||||||
│
|
|
||||||
▼ mp3 stream
|
|
||||||
┌──────────────┐
|
|
||||||
│ mpv │ → speakers (BT)
|
|
||||||
└──────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Сессия диалога теперь **на стороне OpenClaw** — satellite отправляет лишь `x-openclaw-session-key`, а история и память живут в gateway. Клиент stateless.
|
||||||
|
|
||||||
## Инфраструктура
|
## Инфраструктура
|
||||||
|
|
||||||
- **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox
|
- **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox
|
||||||
- **Cosmo Gateway**: порт `18789`, агент `openclaw/main`
|
- **Cosmo Gateway**: порт `18789`, агент `openclaw/main`, session_key `agent:voice:voice:home`
|
||||||
- **Люся Gateway**: порт `18790`, агент `openclaw/wife`
|
- **Люся Gateway**: порт `18790`, агент `openclaw/wife`, session_key `agent:wife:voice:home`
|
||||||
- **Модель**: `openai/gpt-5.4-mini` (через `x-openclaw-model` header)
|
- **Модель**: `openai/gpt-5.4-mini` (через `x-ocplatform-model` header; переопределяется через `VOICE_MODEL`)
|
||||||
- **STT**: Groq API, `whisper-large-v3-turbo`, язык ru
|
- **STT**: Groq API, `whisper-large-v3-turbo`, язык ru
|
||||||
- **TTS**: ElevenLabs, `eleven_flash_v2_5` (~75ms латентность)
|
- **TTS**: ElevenLabs (`eleven_flash_v2_5` / `eleven_turbo_v2_5` / `eleven_multilingual_v2` — выбирается через `ELEVENLABS_MODEL`)
|
||||||
- **Wake word**: Porcupine (на Pi), Enter (при разработке)
|
- **Wake word**: openwakeword (`.onnx`, обучается на своих голосах через `training/step_*.py`). Раньше закладывали Porcupine — отказались.
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
home-voice-assistant/
|
home-voice-assistant/
|
||||||
├── .env # секреты (не в git)
|
├── .env # секреты (не в git)
|
||||||
├── .env.example # шаблон
|
├── .env.example # шаблон
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── satellite.py # обёртка для запуска
|
├── satellite.py # обёртка для запуска
|
||||||
├── satellite/
|
├── satellite/
|
||||||
│ ├── __init__.py
|
│ ├── __main__.py # entry: python -m satellite [--wake]
|
||||||
│ ├── __main__.py # entry: python -m satellite [--wake]
|
│ ├── config.py # env, AGENTS dict, keep-alive sessions
|
||||||
│ ├── config.py # env, AGENTS dict, keep-alive sessions
|
│ ├── text.py # clean_for_speech (+ pymorphy3/num2words для времени)
|
||||||
│ ├── text.py # clean_for_speech, find_sentence_end
|
│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла)
|
||||||
│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла)
|
│ ├── audio.py # record (RMS VAD)
|
||||||
│ ├── audio.py # record, record_with_timeout (VAD)
|
│ ├── tts.py # ElevenLabs streaming через mpv stdin
|
||||||
│ ├── tts.py # ElevenLabs streaming через mpv, barge-in
|
│ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
|
||||||
│ ├── llm.py # ask_agent_stream, Conversation (history)
|
│ └── modes.py # run_with_enter / run_with_porcupine + /new через slash
|
||||||
│ └── modes.py # run_with_enter, run_with_porcupine
|
├── record_wav.py # запись обучающих wav-ов для wake word
|
||||||
|
├── remove_silent.py # чистка тихих записей
|
||||||
|
├── training/ # пайплайн обучения wake word (не в git)
|
||||||
└── deploy/
|
└── deploy/
|
||||||
├── setup.sh # установка на Raspberry Pi
|
├── setup.sh # установка на Raspberry Pi
|
||||||
└── cosmo-satellite.service # systemd unit
|
└── cosmo-satellite.service # systemd unit
|
||||||
```
|
```
|
||||||
|
|
||||||
## Что важно знать
|
## Ключевые инварианты
|
||||||
|
|
||||||
### Сессии диалога
|
### Сессии диалога
|
||||||
- **Одна сессия на день** для каждого агента. Это осознанное решение: каждая новая сессия в OpenClaw тяжёлая (чтение памяти, большой контекст).
|
- История и контекст **на стороне OpenClaw**. Клиент шлёт только текущий user-message + `x-openclaw-session-key`.
|
||||||
- История хранится в `Conversation.messages[]` на клиенте и отправляется целиком с каждым запросом (stateless к серверу).
|
- Сброс: фраза «начни новую сессию» / «сбрось историю» / «очисти контекст» (паттерны в `llm.py::RESET_PATTERNS`) → `modes._handle_reset` делает прямой POST с `/new`.
|
||||||
- Сброс сессии: фраза "начни новую сессию" / "сбрось историю" / "очисти контекст" — паттерны в `RESET_PATTERNS` в `llm.py`.
|
- Автосброс по таймауту **пока не реализован** (кандидат Этапа 1).
|
||||||
- Автосброс при смене даты (`Conversation.is_expired()`).
|
|
||||||
- `MAX_HISTORY=20` — лимит сообщений, чтобы не раздувать контекст.
|
|
||||||
|
|
||||||
### Оптимизации скорости (все уже внедрены)
|
### Оптимизации скорости
|
||||||
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, играет пока генерируется.
|
2. **Streaming TTS** — ElevenLabs аудио пайпится в `mpv` через stdin, играет пока генерируется.
|
||||||
3. **STT без диска** — PCM → WAV в `BytesIO` → Groq, без temp файлов.
|
3. **STT без диска** — PCM → WAV в `BytesIO` → Groq.
|
||||||
4. **Barge-in** — `stop_speaking()` вызывается при каждой активации, убивает текущий mpv процесс.
|
4. **Низкокачественный mp3 для речи** (`mp3_22050_32`) — меньше латентность без заметной потери качества для голоса.
|
||||||
|
5. **`optimize_streaming_latency=3`** в ElevenLabs convert — выдаёт первый чанк быстрее.
|
||||||
|
|
||||||
### Роутинг по wake word
|
### Роутинг по wake word
|
||||||
В `modes.py::run_with_porcupine` Porcupine грузит оба wake word:
|
`modes.py::run_with_porcupine` сейчас грузит только модель Cosmo. Код Люси закомментирован до того, как модель обучена. Когда готова:
|
||||||
- index 0 = Cosmo → `AGENTS["cosmo"]` (:18789)
|
- index 0 → `AGENTS["cosmo"]` (:18789)
|
||||||
- index 1 = Люся → `AGENTS["lusya"]` (:18790)
|
- 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 ловит всё непредвиденное и продолжает цикл.
|
Каждый слой (stt, tts, llm, audio, modes) ловит `Exception` и пишет в `errors.log` через `config.log`. Верхний уровень в modes.py ловит всё непредвиденное и продолжает цикл.
|
||||||
@@ -88,103 +86,126 @@ home-voice-assistant/
|
|||||||
|
|
||||||
### macOS / Windows (разработка)
|
### macOS / Windows (разработка)
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv && source .venv/bin/activate
|
||||||
# macOS/Linux: source .venv/bin/activate
|
|
||||||
# Windows: .venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env # заполнить ключи
|
cp .env.example .env # заполнить ключи
|
||||||
|
|
||||||
python satellite.py # режим Enter (без wake word)
|
python satellite.py # режим Enter (без wake word)
|
||||||
python satellite.py --wake # режим Porcupine (нужны .ppn + PORCUPINE_KEY)
|
python satellite.py --wake # режим openwakeword (нужна обученная .onnx)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Raspberry Pi (продакшн)
|
### Raspberry Pi (продакшн)
|
||||||
```bash
|
```bash
|
||||||
sudo bash deploy/setup.sh
|
sudo bash deploy/setup.sh
|
||||||
# далее:
|
|
||||||
sudo systemctl start cosmo-satellite
|
sudo systemctl start cosmo-satellite
|
||||||
sudo journalctl -u cosmo-satellite -f
|
sudo journalctl -u cosmo-satellite -f
|
||||||
```
|
```
|
||||||
|
|
||||||
## Зависимости системы
|
## Зависимости системы
|
||||||
|
|
||||||
- **Python 3.12+**
|
- Python 3.12+
|
||||||
- **portaudio** — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`)
|
- `portaudio` — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`)
|
||||||
- **mpv** — для воспроизведения TTS (`brew install mpv` / `apt install mpv`)
|
- `mpv` — для воспроизведения TTS (`brew install mpv` / `apt install mpv`)
|
||||||
- **ffmpeg** — опционально, для совместимости форматов
|
|
||||||
|
|
||||||
### 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_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway |
|
||||||
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Токены авторизации |
|
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены |
|
||||||
| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw (`openclaw/main`, `openclaw/wife`) |
|
| `AGENT`, `LUSYA_AGENT` | Имя агента (`openclaw/main`, `openclaw/wife`) |
|
||||||
| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | Модель LLM для голоса |
|
| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) |
|
||||||
|
| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии OpenClaw |
|
||||||
| `GROQ_API_KEY` | Groq для STT |
|
| `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 |
|
| `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID в ElevenLabs |
|
||||||
| `ELEVENLABS_MODEL` | `eleven_flash_v2_5` (быстрый) |
|
| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям wake word |
|
||||||
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто. |
|
| `WAKE_THRESHOLD` | Порог активации (0..1, дефолт 0.5) |
|
||||||
| `PORCUPINE_KEY`, `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Только для `--wake` режима |
|
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто |
|
||||||
| `SILENCE_THRESHOLD=500` | VAD: чувствительность (ниже = ловит тихую речь) |
|
| `SILENCE_THRESHOLD`, `SILENCE_DURATION`, `MAX_DURATION`, `FOLLOWUP_TIMEOUT` | VAD |
|
||||||
| `SILENCE_DURATION=1.5` | Сек тишины = конец фразы |
|
| `TTS_MODE` | `full` (целостная интонация) или `stream` (быстрый старт, рваный) |
|
||||||
| `FOLLOWUP_TIMEOUT=8` | Сек ожидания продолжения диалога |
|
| `ECHO_WARMUP` | Сек пропуска в начале записи (гасит эхо от TTS) |
|
||||||
| `MAX_HISTORY=20` | Макс. сообщений в сессии |
|
|
||||||
|
|
||||||
## Частые задачи
|
## Частые задачи
|
||||||
|
|
||||||
**Сменить голос у агента**: меняй `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`)
|
- Не комитить `.env` (есть в `.gitignore`)
|
||||||
- Не возвращать fallback на macOS `say` — проект специально унифицирован на ElevenLabs + mpv
|
- Не возвращать `say`/`espeak` — проект унифицирован на ElevenLabs + mpv
|
||||||
- Не создавать новую сессию Conversation на каждую активацию — это было в старой версии, сейчас одна сессия на день
|
- Не хранить историю диалога на клиенте — это делает OpenClaw по `session_key`
|
||||||
- Не добавлять temp файлы для WAV/mp3 — всё идёт через `BytesIO` / stdin pipe
|
- Не создавать temp файлы для WAV/mp3 — всё через `BytesIO` / stdin pipe
|
||||||
|
- Не включать `style>0` и `speed≠1.0` в VoiceSettings — усиливают «иностранный» акцент и ломают просодию
|
||||||
|
|
||||||
## Тренировка своего wake word
|
## Тренировка wake word
|
||||||
|
|
||||||
Пайплайн в `training/`:
|
Пайплайн в `training/` (игнорируется в git):
|
||||||
- `record_wav.py <model> <positive|negative>` — запись 16kHz mono PCM 16-bit в `training/own_samples/<model>/`
|
- `record_wav.py <model> <positive|negative> [long <sec>]` — запись 16kHz mono PCM 16-bit
|
||||||
- `training/step_1.py` … `step_5.py` — установка зависимостей, конвертация датасетов, генерация конфига, обучение, экспорт в `data/models/<name>.onnx`
|
- `remove_silent.py` — чистка + перенумерация
|
||||||
- `training/training_config.json` — параметры (`wake_word_list`, `use_own_samples`, штрафы, шаги)
|
- `step_1.py … step_5.py` — зависимости, датасеты, конфиг, обучение, экспорт
|
||||||
- `training/openwakeword/` — форк openwakeword, `examples/custom_model.yml` — базовый шаблон конфига
|
- `training_config.json` — параметры (`wake_word_list`, `use_own_samples`, пенальти, шаги)
|
||||||
- Под капотом: openwakeword (НЕ Porcupine, несмотря на легаси-имена в коде). Wake word работает через DNN-модель .onnx.
|
- Под капотом: openwakeword (DNN .onnx)
|
||||||
|
|
||||||
Реалистичные цифры для своего голоса: 500+ positive и 1000+ negative wav-файлов, иначе recall/FP/hour не сходятся. Negative должны включать фонетически близкие слова.
|
Реалистично для своего голоса: 500+ positive и 1000+ negative, иначе recall < 0.4. Negative должны включать фонетически близкие слова («космос», «просто»).
|
||||||
|
|
||||||
## Roadmap
|
## Состояние и планы
|
||||||
|
|
||||||
### Done
|
### ✅ Done
|
||||||
- [x] Модулизация satellite.py (audio/stt/llm/tts/modes/config)
|
- [x] Модульная структура (`audio/stt/llm/tts/modes/config/text`)
|
||||||
- [x] ElevenLabs streaming TTS + mpv pipe
|
- [x] ElevenLabs streaming + mpv pipe
|
||||||
- [x] Keep-alive HTTP сессии, STT через BytesIO, barge-in
|
- [x] Keep-alive HTTP сессии, STT через BytesIO
|
||||||
- [x] Сессии диалога (одна на день, MAX_HISTORY, паттерны сброса)
|
- [x] Серверные сессии OpenClaw через `x-openclaw-session-key` (не клиентская история)
|
||||||
- [x] Пайплайн тренировки своего wake word на собственных записях
|
- [x] Slash-команда `/new` на фразу «начни новую сессию»
|
||||||
|
- [x] Нормализация речи: числа, единицы, время через pymorphy3 + num2words
|
||||||
|
- [x] Пайплайн тренировки своего wake word + скрипты записи/чистки датасета
|
||||||
|
- [x] systemd unit и setup.sh для Pi 5
|
||||||
|
|
||||||
### In progress
|
### 🚧 In progress / нужно сделать
|
||||||
- [ ] Дообучение модели cosmo (на текущем датасете 300 pos / 117 neg метрики плохие — recall 25%, FP/hr 32). Нужно дозаписать данные.
|
- [ ] **Чистка**: удалить `start_barge_in_listener`/`was_barge_in` из `tts.py`, параметр `conv` из `llm.py`, импорты `sys`/`start_barge_in_listener`/`was_barge_in` из `modes.py`
|
||||||
- [ ] Подключить Люсю в `run_with_wakeword` (сейчас грузится только модель cosmo, lusya wake word не работает)
|
- [ ] **`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
|
### 📋 Roadmap Этап 2 — качество и надёжность
|
||||||
- [ ] systemd autostart на Raspberry Pi (`deploy/cosmo-satellite.service` есть, но не проверен в проде)
|
- [ ] **Автосброс OpenClaw сессии по таймауту** (>1 ч тишины → `/new`)
|
||||||
- [ ] Home Assistant tool в OpenClaw воркспейсе (управление светом/температурой через голос)
|
- [ ] **Retry с backoff** для gateway (3 попытки с экспонентой)
|
||||||
- [ ] Real-time barge-in (прерывание по голосу во время озвучки, не только по новой активации)
|
- [ ] **TTS-cache** для дежурных реплик («Начинаю новую сессию», «Не слышу», «Ошибка сервера»)
|
||||||
- [ ] Контекст окружения в system prompt (время, погода, состояние устройств)
|
- [ ] **Persistent PyAudio input stream** (не пересоздавать на каждый `record()`)
|
||||||
- [ ] Speaker identification (определять кто говорит без разных wake words)
|
- [ ] **Заменить RMS-VAD на `webrtcvad` или `silero-vad`** — RMS не работает с фоновой музыкой
|
||||||
- [ ] Проактивные уведомления (WebSocket от сервера → satellite сам начинает говорить)
|
- [ ] **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** — при потере связи ассистент молчит до явной ошибки.
|
||||||
|
|||||||
242
README.md
242
README.md
@@ -1,74 +1,87 @@
|
|||||||
# Cosmo Voice Satellite
|
# 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 — отказались.
|
- **Wake word**: openwakeword (`.onnx`, обученная на своих записях)
|
||||||
- **STT:** Groq API, `whisper-large-v3-turbo`, ru.
|
- **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`.
|
- **Агент**: OpenClaw на N100, модели через `x-ocplatform-model` header (`openai/gpt-5.4-mini` и т.п.)
|
||||||
- **TTS:** ElevenLabs `eleven_flash_v2_5` стримом через mpv stdin.
|
- **История диалога**: на сервере OpenClaw (per `session_key`), клиент stateless
|
||||||
|
- **TTS**: ElevenLabs streaming через mpv
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```
|
||||||
home-voice-assistant/
|
home-voice-assistant/
|
||||||
├── satellite.py # entry-обёртка
|
├── satellite.py # entry-обёртка
|
||||||
├── satellite/ # рантайм
|
├── satellite/ # рантайм
|
||||||
│ ├── __main__.py # python -m satellite [--wake]
|
│ ├── __main__.py # python -m satellite [--wake]
|
||||||
│ ├── config.py, text.py
|
│ ├── config.py # AGENTS, keep-alive sessions
|
||||||
│ ├── stt.py, audio.py, tts.py, llm.py
|
│ ├── audio.py # запись + RMS VAD
|
||||||
│ └── modes.py # run_with_enter / run_with_porcupine (wake word)
|
│ ├── stt.py # Groq whisper
|
||||||
├── record_wav.py # запись датасета для wake word
|
│ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
|
||||||
├── remove_silent.py # чистка тихих + перенумерация
|
│ ├── tts.py # ElevenLabs → mpv stdin
|
||||||
├── training/ # пайплайн обучения wake word
|
│ ├── text.py # clean_for_speech (+ pymorphy3 для времени)
|
||||||
│ ├── step_1.py … step_5.py
|
│ └── modes.py # run_with_enter / run_with_porcupine
|
||||||
│ ├── training_config.json
|
├── record_wav.py # запись датасета wake word
|
||||||
│ ├── own_samples/<word>/{positive,negative}/*.wav
|
├── remove_silent.py # чистка тихих + перенумерация
|
||||||
│ ├── openwakeword/ # форк
|
├── training/ # openwakeword пайплайн (в .gitignore)
|
||||||
│ └── my_custom_model/<word>/ # фичи + .onnx
|
├── data/models/ # готовые .onnx wake word моделей
|
||||||
├── data/models/ # готовые .onnx wake word моделей
|
└── deploy/ # setup.sh + systemd unit для Pi 5
|
||||||
└── deploy/ # setup.sh + systemd unit для Pi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Запуск
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cp .env.example .env # заполнить ключи
|
cp .env.example .env # заполнить ключи
|
||||||
|
|
||||||
python satellite.py # режим Enter (без wake word, для отладки)
|
python satellite.py # режим Enter (отладка)
|
||||||
python satellite.py --wake # режим wake word (нужна обученная модель в data/models/)
|
python satellite.py --wake # режим wake word (нужна модель в data/models/)
|
||||||
```
|
```
|
||||||
|
|
||||||
Системные зависимости:
|
### Системные зависимости
|
||||||
- Python 3.12+
|
|
||||||
- `portaudio` — `brew install portaudio`
|
macOS: `brew install portaudio mpv`
|
||||||
- `mpv` — `brew install 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
|
## Обучение своего wake word
|
||||||
|
|
||||||
OpenWakeWord обучает DNN-модель на твоих записях слова. Пайплайн в `training/`:
|
OpenWakeWord тренирует DNN-модель на твоих записях слова. Пайплайн в `training/`:
|
||||||
|
|
||||||
| Шаг | Что делает |
|
| Шаг | Что делает |
|
||||||
|-----|-----------|
|
|-----|-----------|
|
||||||
| `step_1.py` | Установка зависимостей (piper, openwakeword) |
|
| `step_1.py` | Установка зависимостей (piper, openwakeword) |
|
||||||
| `step_2.py` | Создаёт `training_config.json` (параметры обучения) |
|
| `step_2.py` | Создаёт `training_config.json` |
|
||||||
| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features) — ~17 GB |
|
| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features, ~17 GB) |
|
||||||
| `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` |
|
| `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` |
|
||||||
| `step_5.py` | Проверка моделей и подсказки для `.env` |
|
| `step_5.py` | Проверка моделей и подсказки для `.env` |
|
||||||
|
|
||||||
### Запись датасета
|
### Запись датасета
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# по одной записи (Enter → 2 секунды → сохраняем)
|
# по одной записи (Enter → 2 с → сохраняем)
|
||||||
python record_wav.py cosmo positive
|
python record_wav.py cosmo positive
|
||||||
python record_wav.py cosmo negative
|
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
|
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`, параметры:
|
```bash
|
||||||
```json
|
# в training/training_config.json:
|
||||||
{
|
# {
|
||||||
"wake_word_list": ["cosmo"],
|
# "wake_word_list": ["cosmo"],
|
||||||
"use_own_samples": true,
|
# "use_own_samples": true,
|
||||||
"false_activation_penalty": 100,
|
# "false_activation_penalty": 100,
|
||||||
"target_false_positives_per_hour": 3.0,
|
# "target_false_positives_per_hour": 3.0,
|
||||||
"target_recall": 0.5,
|
# "target_recall": 0.5,
|
||||||
"number_of_training_steps": 3000,
|
# "number_of_training_steps": 3000,
|
||||||
"layer_size": 64
|
# "layer_size": 64
|
||||||
}
|
# }
|
||||||
```
|
|
||||||
2. В `training/openwakeword/examples/custom_model.yml` подними `augmentation_rounds: 10` (или больше).
|
rm -rf training/my_custom_model/cosmo data/models/cosmo.onnx
|
||||||
3. Снеси кэш если был старый запуск:
|
python training/step_4.py
|
||||||
```bash
|
# → .env: WAKE_WORD_COSMO=data/models/cosmo.onnx
|
||||||
rm -rf training/my_custom_model/<word> data/models/<word>.onnx
|
```
|
||||||
```
|
|
||||||
4. Запусти:
|
|
||||||
```bash
|
|
||||||
python training/step_4.py
|
|
||||||
```
|
|
||||||
5. Пропиши в `.env`: `WAKE_WORD_COSMO=data/models/cosmo.onnx`.
|
|
||||||
|
|
||||||
### Сколько данных нужно
|
### Сколько данных нужно
|
||||||
|
|
||||||
| Positive | Negative | Recall |
|
| Positive | Negative | Ожидаемый recall |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 100–200 | 200+ | 0.1–0.3 (плохо) |
|
| 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 |
|
| 800–1500 | 1000+ | 0.7–0.85 |
|
||||||
| 2000+ | 2000+ | 0.9+ |
|
| 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 (ключевые переменные)
|
## .env (ключевые переменные)
|
||||||
|
|
||||||
| Переменная | Что |
|
| Переменная | Что |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | OpenClaw gateways |
|
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway |
|
||||||
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Авторизация |
|
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены |
|
||||||
| `AGENT`, `LUSYA_AGENT` | `openclaw/main`, `openclaw/wife` |
|
| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw |
|
||||||
| `VOICE_MODEL` | LLM для голоса (передаётся в `x-openclaw-model`) |
|
| `VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) |
|
||||||
|
| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии |
|
||||||
| `GROQ_API_KEY` | STT |
|
| `GROQ_API_KEY` | STT |
|
||||||
| `ELEVENLABS_API_KEY`, `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | TTS |
|
| `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL` | TTS |
|
||||||
| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям |
|
| `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID |
|
||||||
| `SILENCE_THRESHOLD`, `SILENCE_DURATION` | VAD |
|
| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` |
|
||||||
| `MAX_HISTORY` | Лимит сообщений в сессии |
|
| `WAKE_THRESHOLD` | Порог активации (дефолт 0.5) |
|
||||||
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink` |
|
| `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
|
```bash
|
||||||
sudo bash deploy/setup.sh
|
sudo bash deploy/setup.sh
|
||||||
|
# подключи BT колонку, пропиши AUDIO_SINK в .env
|
||||||
|
# положи обученную .onnx в data/models/cosmo.onnx
|
||||||
sudo systemctl start cosmo-satellite
|
sudo systemctl start cosmo-satellite
|
||||||
sudo journalctl -u cosmo-satellite -f
|
sudo journalctl -u cosmo-satellite -f
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] Модулизация satellite
|
### ✅ Сделано
|
||||||
- [x] ElevenLabs streaming + barge-in
|
|
||||||
- [x] Сессии диалога с автосбросом
|
- Модульная структура satellite
|
||||||
- [x] Пайплайн тренировки wake word на своих записях
|
- ElevenLabs streaming TTS через mpv pipe
|
||||||
- [ ] Обучить рабочую модель cosmo (нужно ~500+ позитивов)
|
- Keep-alive HTTP + STT без диска
|
||||||
- [ ] Подключить Люсю в `run_with_porcupine` (сейчас грузится только cosmo)
|
- Серверные сессии OpenClaw (`x-openclaw-session-key`)
|
||||||
- [ ] Проверить systemd autostart на Pi в проде
|
- Slash-команда `/new` для сброса голосом
|
||||||
- [ ] Home Assistant tool в OpenClaw
|
- Нормализация речи (числа, время, единицы) через pymorphy3 + num2words
|
||||||
- [ ] Real-time barge-in (прерывание голосом во время TTS)
|
- Пайплайн тренировки wake word на своих записях
|
||||||
- [ ] Контекст окружения в system prompt
|
- systemd unit для Pi
|
||||||
- [ ] Speaker identification
|
|
||||||
- [ ] Проактивные уведомления через WebSocket
|
### 🚧 В работе
|
||||||
|
|
||||||
|
- Дообучить 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 позитивов с разнообразием.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ numpy<2
|
|||||||
pyaudio
|
pyaudio
|
||||||
sounddevice
|
sounddevice
|
||||||
scipy<1.15
|
scipy<1.15
|
||||||
|
webrtcvad-wheels
|
||||||
|
|
||||||
# STT через облако
|
# STT через облако
|
||||||
groq
|
groq
|
||||||
|
|||||||
@@ -2,22 +2,59 @@ import os
|
|||||||
import pyaudio
|
import pyaudio
|
||||||
import numpy as np
|
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
|
from .stt import transcribe
|
||||||
|
|
||||||
ECHO_WARMUP = float(os.getenv("ECHO_WARMUP", "0.5")) # сек пропуска в начале — гасит эхо от TTS
|
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:
|
try:
|
||||||
audio = pyaudio.PyAudio()
|
audio = pyaudio.PyAudio()
|
||||||
stream = audio.open(
|
stream = audio.open(
|
||||||
format=pyaudio.paInt16,
|
format=pyaudio.paInt16,
|
||||||
channels=1,
|
channels=1,
|
||||||
rate=16000,
|
rate=SAMPLE_RATE,
|
||||||
input=True,
|
input=True,
|
||||||
frames_per_buffer=1024,
|
frames_per_buffer=FRAME_SAMPLES,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Не удалось открыть микрофон")
|
log.exception("Не удалось открыть микрофон")
|
||||||
@@ -25,30 +62,38 @@ def record() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
print("🎙️ Говори...")
|
print("🎙️ Говори...")
|
||||||
frames = []
|
frames: list[bytes] = []
|
||||||
silent_chunks = 0
|
|
||||||
speaking_started = False
|
speaking_started = False
|
||||||
max_chunks = int(16000 / 1024 * MAX_DURATION)
|
trailing_silence = 0 # фреймы тишины после начала речи
|
||||||
silence_chunks_needed = int(16000 / 1024 * SILENCE_DURATION)
|
initial_silence = 0 # фреймы тишины до начала речи
|
||||||
warmup_chunks = int(16000 / 1024 * ECHO_WARMUP)
|
|
||||||
|
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:
|
try:
|
||||||
for i in range(max_chunks):
|
for i in range(max_frames):
|
||||||
data = stream.read(1024, exception_on_overflow=False)
|
data = stream.read(FRAME_SAMPLES, exception_on_overflow=False)
|
||||||
if i < warmup_chunks:
|
if i < warmup_frames:
|
||||||
continue # гасим эхо от TTS / звука активации
|
continue
|
||||||
frames.append(data)
|
frames.append(data)
|
||||||
|
|
||||||
amplitude = np.abs(np.frombuffer(data, dtype=np.int16)).mean()
|
if _is_speech(data):
|
||||||
|
|
||||||
if amplitude > SILENCE_THRESHOLD:
|
|
||||||
speaking_started = True
|
speaking_started = True
|
||||||
silent_chunks = 0
|
trailing_silence = 0
|
||||||
elif speaking_started:
|
else:
|
||||||
silent_chunks += 1
|
if speaking_started:
|
||||||
if silent_chunks >= silence_chunks_needed:
|
trailing_silence += 1
|
||||||
print("🔇 Конец речи")
|
if trailing_silence >= silence_frames_needed:
|
||||||
break
|
print("🔇 Конец речи")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
initial_silence += 1
|
||||||
|
if initial_silence >= initial_silence_limit:
|
||||||
|
print("😴 Пользователь молчит, выхожу")
|
||||||
|
speaking_started = False
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Ошибка при записи аудио")
|
log.exception("Ошибка при записи аудио")
|
||||||
print(f"⚠️ Ошибка записи: {e}")
|
print(f"⚠️ Ошибка записи: {e}")
|
||||||
|
|||||||
@@ -20,15 +20,14 @@ logging.basicConfig(
|
|||||||
log = logging.getLogger("cosmo")
|
log = logging.getLogger("cosmo")
|
||||||
|
|
||||||
# OpenClaw Gateway — Cosmo (по умолчанию)
|
# OpenClaw Gateway — Cosmo (по умолчанию)
|
||||||
|
# Роутинг к нужному агенту делается через x-openclaw-session-key, поэтому AGENT не нужен.
|
||||||
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://192.168.31.103:18789")
|
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://192.168.31.103:18789")
|
||||||
GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN")
|
GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN")
|
||||||
AGENT = os.getenv("AGENT", "openclaw/main")
|
|
||||||
VOICE_MODEL = os.getenv("VOICE_MODEL", "openai/gpt-4o-mini")
|
VOICE_MODEL = os.getenv("VOICE_MODEL", "openai/gpt-4o-mini")
|
||||||
|
|
||||||
# OpenClaw Gateway — Люся
|
# OpenClaw Gateway — Люся
|
||||||
LUSYA_GATEWAY_URL = os.getenv("LUSYA_GATEWAY_URL", "http://192.168.31.103:18790")
|
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_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)
|
LUSYA_VOICE_MODEL = os.getenv("LUSYA_VOICE_MODEL", VOICE_MODEL)
|
||||||
|
|
||||||
# Keep-alive HTTP сессии — переиспользуют TCP/TLS соединения
|
# Keep-alive HTTP сессии — переиспользуют TCP/TLS соединения
|
||||||
@@ -46,20 +45,16 @@ AGENTS = {
|
|||||||
"cosmo": {
|
"cosmo": {
|
||||||
"name": "Cosmo",
|
"name": "Cosmo",
|
||||||
"gateway_url": GATEWAY_URL,
|
"gateway_url": GATEWAY_URL,
|
||||||
"token": GATEWAY_TOKEN,
|
|
||||||
"agent": AGENT,
|
|
||||||
"voice_model": VOICE_MODEL,
|
"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", ""),
|
"tts_voice": os.getenv("COSMO_TTS_VOICE", ""),
|
||||||
"session": _make_session(GATEWAY_TOKEN),
|
"session": _make_session(GATEWAY_TOKEN),
|
||||||
},
|
},
|
||||||
"lusya": {
|
"lusya": {
|
||||||
"name": "Люся",
|
"name": "Люся",
|
||||||
"gateway_url": LUSYA_GATEWAY_URL,
|
"gateway_url": LUSYA_GATEWAY_URL,
|
||||||
"token": LUSYA_GATEWAY_TOKEN,
|
|
||||||
"agent": LUSYA_AGENT,
|
|
||||||
"voice_model": LUSYA_VOICE_MODEL,
|
"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", ""),
|
"tts_voice": os.getenv("LUSYA_TTS_VOICE", ""),
|
||||||
"session": _make_session(LUSYA_GATEWAY_TOKEN),
|
"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"))
|
SILENCE_DURATION = float(os.getenv("SILENCE_DURATION", "1.5"))
|
||||||
MAX_DURATION = int(os.getenv("MAX_DURATION", "15"))
|
MAX_DURATION = int(os.getenv("MAX_DURATION", "15"))
|
||||||
FOLLOWUP_TIMEOUT = float(os.getenv("FOLLOWUP_TIMEOUT", "8"))
|
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_client = Groq(api_key=os.getenv("GROQ_API_KEY"))
|
groq_client = Groq(api_key=os.getenv("GROQ_API_KEY"))
|
||||||
|
|||||||
117
satellite/llm.py
117
satellite/llm.py
@@ -1,13 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import requests
|
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 .text import clean_for_speech, find_sentence_end
|
||||||
from .tts import speak, play_error_sound
|
from .tts import speak, play_error_sound
|
||||||
|
|
||||||
# Ключ голосовой сессии — Cosmo работает как полноценный агент
|
|
||||||
VOICE_SESSION_KEY = os.getenv("VOICE_SESSION_KEY", "agent:main:voice:home")
|
VOICE_SESSION_KEY = os.getenv("VOICE_SESSION_KEY", "agent:main:voice:home")
|
||||||
|
|
||||||
# "stream" — режем по предложениям (быстро, но рваная интонация)
|
# "stream" — режем по предложениям (быстро, но рваная интонация)
|
||||||
@@ -26,67 +26,86 @@ FILLER_PATTERNS = re.compile(
|
|||||||
r'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю'
|
r'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю'
|
||||||
r'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник'
|
r'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник'
|
||||||
r'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\s*)+',
|
r'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\s*)+',
|
||||||
re.IGNORECASE
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def strip_fillers(text: str) -> str:
|
def strip_fillers(text: str) -> str:
|
||||||
return FILLER_PATTERNS.sub('', text).strip()
|
return FILLER_PATTERNS.sub('', text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def is_reset_command(text: str) -> bool:
|
def is_reset_command(text: str) -> bool:
|
||||||
return bool(RESET_PATTERNS.search(text))
|
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 и озвучивает ответ."""
|
"""Отправляет запрос к OpenClaw gateway и озвучивает ответ."""
|
||||||
def _maybe_speak(t: str):
|
def _maybe_speak(t: str):
|
||||||
if t.strip():
|
if t.strip():
|
||||||
speak(t, agent_id)
|
speak(t, agent_id)
|
||||||
|
|
||||||
cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
|
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)
|
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:
|
try:
|
||||||
resp = session.post(
|
resp = _post_with_retry(
|
||||||
f"{gateway_url}/v1/chat/completions",
|
cfg["session"], f"{cfg['gateway_url']}/v1/chat/completions", headers, payload,
|
||||||
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.raise_for_status()
|
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
log.exception("Gateway недоступен")
|
log.exception("Gateway недоступен после retry")
|
||||||
msg = "Не могу связаться с сервером, попробуй ещё раз."
|
msg = "Не могу связаться с сервером, попробуй ещё раз."
|
||||||
print(f"⚠️ {msg}")
|
print(f"⚠️ {msg}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
_maybe_speak(msg)
|
_maybe_speak(msg)
|
||||||
return msg
|
return msg
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
log.exception("Gateway таймаут")
|
log.exception("Gateway таймаут после retry")
|
||||||
msg = "Сервер не ответил вовремя, попробуй ещё раз."
|
msg = "Сервер не ответил вовремя, попробуй ещё раз."
|
||||||
print(f"⚠️ {msg}")
|
print(f"⚠️ {msg}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
_maybe_speak(msg)
|
_maybe_speak(msg)
|
||||||
return msg
|
return msg
|
||||||
except requests.HTTPError:
|
except requests.HTTPError as e:
|
||||||
log.exception(f"Gateway HTTP ошибка {resp.status_code}")
|
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 = "Ошибка сервера, попробуй ещё раз."
|
msg = "Ошибка сервера, попробуй ещё раз."
|
||||||
print(f"⚠️ Gateway {resp.status_code}: {resp.text}")
|
print(f"⚠️ Gateway {status}: {body[:200]}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
_maybe_speak(msg)
|
_maybe_speak(msg)
|
||||||
return 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():
|
for line in resp.iter_lines():
|
||||||
if not line or line == b"data: [DONE]":
|
if not line or line == b"data: [DONE]":
|
||||||
continue
|
continue
|
||||||
if line.startswith(b"data: "):
|
if not line.startswith(b"data: "):
|
||||||
try:
|
continue
|
||||||
chunk = json.loads(line[6:])
|
try:
|
||||||
delta = chunk["choices"][0]["delta"].get("content", "")
|
chunk = json.loads(line[6:])
|
||||||
if not delta:
|
delta = chunk["choices"][0]["delta"].get("content", "")
|
||||||
continue
|
if not delta:
|
||||||
|
|
||||||
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):
|
|
||||||
continue
|
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:
|
except Exception as e:
|
||||||
log.exception("Ошибка при чтении стрима")
|
log.exception("Ошибка при чтении стрима")
|
||||||
print(f"⚠️ Стрим прервался: {e}")
|
print(f"⚠️ Стрим прервался: {e}")
|
||||||
@@ -132,6 +151,6 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
|
|||||||
_maybe_speak(result)
|
_maybe_speak(result)
|
||||||
else:
|
else:
|
||||||
if buffer.strip():
|
if buffer.strip():
|
||||||
_maybe_speak(clean_for_speech(buffer))
|
_maybe_speak(clean_for_speech(strip_fillers(buffer)))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import os
|
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 .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
|
from .llm import ask_agent_stream, is_reset_command, VOICE_SESSION_KEY
|
||||||
|
|
||||||
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
|
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),
|
"x-openclaw-session-key": cfg.get("session_key", VOICE_SESSION_KEY),
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": cfg["agent"],
|
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"messages": [{"role": "user", "content": "/new"}],
|
"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"):
|
def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
|
||||||
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит."""
|
"""Основной цикл диалога.
|
||||||
|
Первая запись — с большим таймаутом (MAX_DURATION), дальше — короткий FOLLOWUP_TIMEOUT."""
|
||||||
|
first = True
|
||||||
while True:
|
while True:
|
||||||
text = record()
|
timeout = MAX_DURATION if first else FOLLOWUP_TIMEOUT
|
||||||
|
first = False
|
||||||
|
text = record(initial_silence_timeout=timeout)
|
||||||
if not text:
|
if not text:
|
||||||
print(f"😴 Тишина, жду активации...\n")
|
print("😴 Тишина, жду активации...\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"📝 Ты → {agent_name}: {text}")
|
print(f"📝 Ты → {agent_name}: {text}")
|
||||||
@@ -59,7 +61,6 @@ def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
|
|||||||
def run_with_enter():
|
def run_with_enter():
|
||||||
print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)")
|
print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)")
|
||||||
print(f" Gateway : {GATEWAY_URL}")
|
print(f" Gateway : {GATEWAY_URL}")
|
||||||
print(f" Агент : {AGENT}")
|
|
||||||
print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n")
|
print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -97,7 +98,6 @@ def run_with_porcupine():
|
|||||||
input=True, frames_per_buffer=1280)
|
input=True, frames_per_buffer=1280)
|
||||||
|
|
||||||
print("✅ Слушаю через OpenWakeWord...")
|
print("✅ Слушаю через OpenWakeWord...")
|
||||||
# print("\nСкажи 'Космо' или 'Люся'...\n") # TODO: после подключения Люси
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -110,12 +110,7 @@ def run_with_porcupine():
|
|||||||
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
|
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
|
||||||
|
|
||||||
if cosmo_score > WAKE_THRESHOLD:
|
if cosmo_score > WAKE_THRESHOLD:
|
||||||
if is_speaking():
|
stop_speaking() # на случай если TTS ещё играет
|
||||||
# Barge-in: прерываем TTS
|
|
||||||
print("✋ Barge-in: прерываю ответ")
|
|
||||||
stop_speaking()
|
|
||||||
cosmo_model.reset()
|
|
||||||
continue
|
|
||||||
stream.stop_stream()
|
stream.stop_stream()
|
||||||
_conversation_loop("cosmo", "Cosmo")
|
_conversation_loop("cosmo", "Cosmo")
|
||||||
cosmo_model.reset()
|
cosmo_model.reset()
|
||||||
@@ -124,10 +119,8 @@ def run_with_porcupine():
|
|||||||
|
|
||||||
# TODO: Люся — раскомментировать когда модель готова
|
# TODO: Люся — раскомментировать когда модель готова
|
||||||
# lusya_score = lusya_model.predict(pcm)["lusya"]
|
# lusya_score = lusya_model.predict(pcm)["lusya"]
|
||||||
# if lusya_score > 0.1:
|
# if lusya_score > WAKE_THRESHOLD:
|
||||||
# print(f"PREDICTION lusya: {lusya_score:.3f}")
|
# stop_speaking()
|
||||||
# if lusya_score > 0.5:
|
|
||||||
# print("✅ Услышала 'Люся'!")
|
|
||||||
# stream.stop_stream()
|
# stream.stop_stream()
|
||||||
# _conversation_loop("lusya", "Люся")
|
# _conversation_loop("lusya", "Люся")
|
||||||
# lusya_model.reset()
|
# lusya_model.reset()
|
||||||
|
|||||||
131
satellite/tts.py
131
satellite/tts.py
@@ -1,10 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from elevenlabs import VoiceSettings
|
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_API_KEY = os.getenv("ELEVENLABS_API_KEY", "")
|
||||||
ELEVENLABS_MODEL = os.getenv("ELEVENLABS_MODEL", "eleven_flash_v2_5")
|
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
|
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]:
|
def _mpv_cmd() -> list[str]:
|
||||||
"""Команда mpv для воспроизведения из stdin"""
|
|
||||||
mpv_bin = os.getenv("MPV_PATH", "mpv")
|
mpv_bin = os.getenv("MPV_PATH", "mpv")
|
||||||
cmd = [mpv_bin, "--no-video", "--really-quiet", "--no-terminal"]
|
cmd = [mpv_bin, "--no-video", "--really-quiet", "--no-terminal"]
|
||||||
if AUDIO_SINK:
|
if AUDIO_SINK:
|
||||||
@@ -87,13 +51,19 @@ def _mpv_cmd() -> list[str]:
|
|||||||
return cmd
|
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:
|
try:
|
||||||
|
if BARGE_IN_ENABLED:
|
||||||
|
return _speak_with_barge_in(text, agent_id)
|
||||||
_speak_elevenlabs(text, agent_id)
|
_speak_elevenlabs(text, agent_id)
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("TTS ошибка")
|
log.exception("TTS ошибка")
|
||||||
print(f"⚠️ Ошибка воспроизведения: {e}")
|
print(f"⚠️ Ошибка воспроизведения: {e}")
|
||||||
play_error_sound()
|
play_error_sound()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _speak_elevenlabs(text: str, agent_id: str):
|
def _speak_elevenlabs(text: str, agent_id: str):
|
||||||
@@ -107,11 +77,11 @@ def _speak_elevenlabs(text: str, agent_id: str):
|
|||||||
return
|
return
|
||||||
|
|
||||||
voice_settings = VoiceSettings(
|
voice_settings = VoiceSettings(
|
||||||
stability=0.4, # ниже = живее интонация (для multilingual_v2)
|
stability=0.4,
|
||||||
similarity_boost=0.8,
|
similarity_boost=0.8,
|
||||||
style=0.1, # выше = эмоциональнее
|
style=0.1,
|
||||||
use_speaker_boost=True,
|
use_speaker_boost=True,
|
||||||
speed=1.1
|
speed=1.1,
|
||||||
)
|
)
|
||||||
|
|
||||||
audio_stream = client.text_to_speech.convert(
|
audio_stream = client.text_to_speech.convert(
|
||||||
@@ -120,7 +90,7 @@ def _speak_elevenlabs(text: str, agent_id: str):
|
|||||||
model_id=ELEVENLABS_MODEL,
|
model_id=ELEVENLABS_MODEL,
|
||||||
output_format="mp3_22050_32",
|
output_format="mp3_22050_32",
|
||||||
voice_settings=voice_settings,
|
voice_settings=voice_settings,
|
||||||
optimize_streaming_latency=3
|
optimize_streaming_latency=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
with _process_lock:
|
with _process_lock:
|
||||||
@@ -148,9 +118,74 @@ def _speak_elevenlabs(text: str, agent_id: str):
|
|||||||
_current_process = None
|
_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):
|
def _play_sound_file(filename: str, wait: bool = False):
|
||||||
"""Воспроизводит файл из папки sounds/ через mpv.
|
|
||||||
wait=True — блокирует до конца воспроизведения."""
|
|
||||||
sounds_dir = os.path.join(os.path.dirname(__file__), "..", "sounds")
|
sounds_dir = os.path.join(os.path.dirname(__file__), "..", "sounds")
|
||||||
path = os.path.normpath(os.path.join(sounds_dir, filename))
|
path = os.path.normpath(os.path.join(sounds_dir, filename))
|
||||||
mpv_bin = os.getenv("MPV_PATH", "mpv")
|
mpv_bin = os.getenv("MPV_PATH", "mpv")
|
||||||
@@ -162,7 +197,6 @@ def _play_sound_file(filename: str, wait: bool = False):
|
|||||||
|
|
||||||
|
|
||||||
def play_activation_sound():
|
def play_activation_sound():
|
||||||
"""Звук активации — неблокирующий"""
|
|
||||||
try:
|
try:
|
||||||
_play_sound_file("Success_Cosmo.mp3", wait=False)
|
_play_sound_file("Success_Cosmo.mp3", wait=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -170,7 +204,6 @@ def play_activation_sound():
|
|||||||
|
|
||||||
|
|
||||||
def play_error_sound():
|
def play_error_sound():
|
||||||
"""Звук ошибки — 'не получилось'"""
|
|
||||||
try:
|
try:
|
||||||
_play_sound_file("Error_Cosmo.mp3")
|
_play_sound_file("Error_Cosmo.mp3")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user