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
|
||||
# Роутинг к агенту идёт через 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
|
||||
|
||||
233
CLAUDE.md
233
CLAUDE.md
@@ -1,40 +1,34 @@
|
||||
# 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 └──────────────┘
|
||||
│ 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 — отказались.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -45,41 +39,45 @@ home-voice-assistant/
|
||||
├── requirements.txt
|
||||
├── 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
|
||||
│ ├── text.py # clean_for_speech (+ pymorphy3/num2words для времени)
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
## Что важно знать
|
||||
## Ключевые инварианты
|
||||
|
||||
### Сессии диалога
|
||||
- **Одна сессия на день** для каждого агента. Это осознанное решение: каждая новая сессия в 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 <model> <positive|negative>` — запись 16kHz mono PCM 16-bit в `training/own_samples/<model>/`
|
||||
- `training/step_1.py` … `step_5.py` — установка зависимостей, конвертация датасетов, генерация конфига, обучение, экспорт в `data/models/<name>.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 <model> <positive|negative> [long <sec>]` — запись 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** — при потере связи ассистент молчит до явной ошибки.
|
||||
|
||||
224
README.md
224
README.md
@@ -1,19 +1,24 @@
|
||||
# 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
|
||||
|
||||
## Структура
|
||||
|
||||
@@ -22,53 +27,61 @@ 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
|
||||
│ ├── 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/ # пайплайн обучения wake word
|
||||
│ ├── step_1.py … step_5.py
|
||||
│ ├── training_config.json
|
||||
│ ├── own_samples/<word>/{positive,negative}/*.wav
|
||||
│ ├── openwakeword/ # форк
|
||||
│ └── my_custom_model/<word>/ # фичи + .onnx
|
||||
├── training/ # openwakeword пайплайн (в .gitignore)
|
||||
├── data/models/ # готовые .onnx wake word моделей
|
||||
└── deploy/ # setup.sh + systemd unit для Pi
|
||||
└── 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 # заполнить ключи
|
||||
|
||||
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/<word> data/models/<word>.onnx
|
||||
```
|
||||
4. Запусти:
|
||||
```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
|
||||
```
|
||||
5. Пропиши в `.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 позитивов с разнообразием.
|
||||
|
||||
@@ -6,6 +6,7 @@ numpy<2
|
||||
pyaudio
|
||||
sounddevice
|
||||
scipy<1.15
|
||||
webrtcvad-wheels
|
||||
|
||||
# STT через облако
|
||||
groq
|
||||
|
||||
@@ -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:
|
||||
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}")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
try:
|
||||
resp = session.post(
|
||||
f"{gateway_url}/v1/chat/completions",
|
||||
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,
|
||||
},
|
||||
json={
|
||||
"model": agent,
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": text}],
|
||||
"max_tokens": 150,
|
||||
},
|
||||
stream=True,
|
||||
timeout=60,
|
||||
}
|
||||
|
||||
try:
|
||||
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,7 +117,8 @@ 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: "):
|
||||
if not line.startswith(b"data: "):
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
delta = chunk["choices"][0]["delta"].get("content", "")
|
||||
@@ -111,10 +131,9 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
|
||||
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])
|
||||
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:
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
131
satellite/tts.py
131
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:
|
||||
|
||||
Reference in New Issue
Block a user