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:
2026-04-16 17:10:59 +03:00
parent a885cbe74b
commit a9001aef92
9 changed files with 541 additions and 358 deletions

View File

@@ -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

233
CLAUDE.md
View File

@@ -1,40 +1,34 @@
# 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 — отказались.
## Структура проекта ## Структура проекта
@@ -45,41 +39,45 @@ home-voice-assistant/
├── 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, find_sentence_end │ ├── text.py # clean_for_speech (+ pymorphy3/num2words для времени)
│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла) │ ├── stt.py # transcribe (Groq, BytesIO, без temp файла)
│ ├── audio.py # record, record_with_timeout (VAD) │ ├── audio.py # record (RMS VAD)
│ ├── tts.py # ElevenLabs streaming через mpv, barge-in │ ├── tts.py # ElevenLabs streaming через mpv stdin
│ ├── llm.py # ask_agent_stream, Conversation (history) │ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
│ └── modes.py # run_with_enter, run_with_porcupine │ └── modes.py # run_with_enter / run_with_porcupine + /new через slash
├── 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** — при потере связи ассистент молчит до явной ошибки.

230
README.md
View File

@@ -1,19 +1,24 @@
# 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
## Структура ## Структура
@@ -22,53 +27,61 @@ 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
│ ├── 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 # чистка тихих + перенумерация ├── remove_silent.py # чистка тихих + перенумерация
├── training/ # пайплайн обучения wake word ├── training/ # openwakeword пайплайн (в .gitignore)
│ ├── step_1.py … step_5.py
│ ├── training_config.json
│ ├── own_samples/<word>/{positive,negative}/*.wav
│ ├── openwakeword/ # форк
│ └── my_custom_model/<word>/ # фичи + .onnx
├── data/models/ # готовые .onnx wake word моделей ├── data/models/ # готовые .onnx wake word моделей
└── deploy/ # setup.sh + systemd unit для Pi └── deploy/ # setup.sh + systemd unit для Pi 5
``` ```
## Запуск ## Быстрый старт
```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 |
|---|---|---| |---|---|---|
| 100200 | 200+ | 0.10.3 (плохо) | | 100200 | 200+ | 0.10.3 (плохо) |
| 300500 | 500+ | 0.40.6 (минимум) | | 300500 | 500+ | 0.40.6 (минимум для работы) |
| 8001500 | 1000+ | 0.70.85 | | 8001500 | 1000+ | 0.70.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 позитивов с разнообразием.

View File

@@ -6,6 +6,7 @@ numpy<2
pyaudio pyaudio
sounddevice sounddevice
scipy<1.15 scipy<1.15
webrtcvad-wheels
# STT через облако # STT через облако
groq groq

View File

@@ -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
if trailing_silence >= silence_frames_needed:
print("🔇 Конец речи") print("🔇 Конец речи")
break 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}")

View File

@@ -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"))

View File

@@ -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)
try: payload = {
resp = session.post(
f"{gateway_url}/v1/chat/completions",
headers={
"x-ocplatform-model": cfg["voice_model"],
"x-openclaw-session-key": session_key,
},
json={
"model": agent,
"stream": True, "stream": True,
"messages": [{"role": "user", "content": text}], "messages": [{"role": "user", "content": text}],
"max_tokens": 150, "max_tokens": VOICE_MAX_TOKENS,
}, }
stream=True, headers = {
timeout=60, "x-ocplatform-model": cfg["voice_model"],
"x-openclaw-session-key": session_key,
}
try:
resp = _post_with_retry(
cfg["session"], f"{cfg['gateway_url']}/v1/chat/completions", headers, payload,
) )
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,7 +117,8 @@ 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: "):
continue
try: try:
chunk = json.loads(line[6:]) chunk = json.loads(line[6:])
delta = chunk["choices"][0]["delta"].get("content", "") 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": if TTS_MODE == "stream":
last_punct = find_sentence_end(buffer, min_len=120) last_punct = find_sentence_end(buffer, min_len=120)
if last_punct > -1: 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) _maybe_speak(sentence)
buffer = buffer[last_punct + 1:].lstrip() buffer = buffer[last_punct + 1:].lstrip()
except (json.JSONDecodeError, KeyError, IndexError): except (json.JSONDecodeError, KeyError, IndexError):
continue continue
except Exception as e: except Exception as 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

View File

@@ -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()

View File

@@ -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: