Compare commits
23 Commits
feature/op
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c42f3d06 | ||
|
|
f530607503 | ||
|
|
c7df540c0b | ||
|
|
d1f95669e0 | ||
|
|
5a2d34d268 | ||
|
|
356543afdb | ||
|
|
05de9c284b | ||
|
|
584e21923c | ||
|
|
e4e7529063 | ||
| a9001aef92 | |||
|
|
a885cbe74b | ||
|
|
cdf8748e48 | ||
|
|
cd921e1540 | ||
| 3301b3559d | |||
| 0f4ae3a80c | |||
|
|
cc9de661cc | ||
|
|
182e7875ab | ||
| a0618c961d | |||
| cc8cbefe18 | |||
|
|
24c8e38be6 | ||
| 09d22177cd | |||
| 0494c24c47 | |||
| 28cccbdac1 |
48
.env.example
48
.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,8 +30,51 @@ 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
|
||||
|
||||
VOICE_SESSION_KEY=agent:main:voice:home
|
||||
COSMO_SESSION_KEY=agent:voice:voice:home
|
||||
LUSYA_SESSION_KEY=agent:wife:voice:home
|
||||
|
||||
# Smart Home Tablet integration (опционально)
|
||||
# Если настроено — скрипт шлёт события состояния (wake/command/response/idle/error)
|
||||
# на планшет, который показывает оверлей с Siri-blob + распознанным текстом.
|
||||
# Если не настроено, просто пропускается, ассистент работает как раньше.
|
||||
TABLET_URL=https://tablet.digital-home.site
|
||||
VOICE_API_KEY=your_voice_api_key_here
|
||||
|
||||
# TABLET_TTS_ENABLED=true (по умолчанию true когда TABLET_URL/KEY заданы) —
|
||||
# голос ассистента проигрывается на планшете через ElevenLabs proxy,
|
||||
# локальный mpv/speak пропускается. false = говорим локально как раньше.
|
||||
TABLET_TTS_ENABLED=true
|
||||
|
||||
# ——————————————————————————————————————————————
|
||||
# LLM backend
|
||||
# openclaw (дефолт) — существующий путь через gateway с памятью на сервере
|
||||
# claude — прямой вызов Anthropic Haiku 4.5 с локальной историей
|
||||
# и prompt caching (быстрее + дешевле, но без tools)
|
||||
LLM_BACKEND=openclaw
|
||||
|
||||
# Для LLM_BACKEND=claude:
|
||||
ANTHROPIC_API_KEY=your_anthropic_key_here
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5
|
||||
HISTORY_DIR=data/history # куда сохранять JSON истории per-agent per-date
|
||||
MAX_HISTORY=40 # лимит сообщений в истории
|
||||
|
||||
# Egress proxy для non-RU сервисов (Anthropic, Groq, OpenAI).
|
||||
# httpx и requests подхватывают автоматически. Пусто = прямой выход.
|
||||
HTTPS_PROXY=http://192.168.31.103:8888
|
||||
HTTP_PROXY=http://192.168.31.103:8888
|
||||
NO_PROXY=localhost,127.0.0.1,192.168.31.0/24
|
||||
|
||||
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,12 +6,21 @@ numpy<2
|
||||
pyaudio
|
||||
sounddevice
|
||||
scipy<1.15
|
||||
webrtcvad-wheels
|
||||
|
||||
# STT через облако
|
||||
groq
|
||||
|
||||
# LLM — прямой Claude (альтернатива OpenClaw, активируется LLM_BACKEND=claude)
|
||||
anthropic>=0.50.0
|
||||
|
||||
# TTS
|
||||
elevenlabs
|
||||
|
||||
# Wake word
|
||||
openwakeword
|
||||
|
||||
# Русская морфология для нормализации текста под TTS
|
||||
num2words
|
||||
pymorphy3
|
||||
pymorphy3-dicts-ru
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -19,16 +19,19 @@ logging.basicConfig(
|
||||
)
|
||||
log = logging.getLogger("cosmo")
|
||||
|
||||
# OpenClaw Gateway — Cosmo (по умолчанию)
|
||||
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")
|
||||
# Какой LLM backend — openclaw (дефолт) или claude (прямой Anthropic).
|
||||
# В конфиге используется для решения «требовать ли OpenClaw credentials».
|
||||
LLM_BACKEND_CFG = os.getenv("LLM_BACKEND", "openclaw").lower()
|
||||
|
||||
# OpenClaw Gateway — Cosmo
|
||||
# Нужны только если LLM_BACKEND=openclaw. При claude-бэкенде остаются пустыми — это ок.
|
||||
GATEWAY_URL = os.getenv("GATEWAY_URL", "")
|
||||
GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN", "")
|
||||
VOICE_MODEL = os.getenv("VOICE_MODEL", "")
|
||||
|
||||
# OpenClaw Gateway — Люся
|
||||
LUSYA_GATEWAY_URL = os.getenv("LUSYA_GATEWAY_URL", "http://192.168.31.103:18790")
|
||||
LUSYA_GATEWAY_URL = os.getenv("LUSYA_GATEWAY_URL", "")
|
||||
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 +49,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,10 +72,22 @@ 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"))
|
||||
|
||||
if not GATEWAY_TOKEN:
|
||||
print("❌ GATEWAY_TOKEN не задан в .env")
|
||||
if LLM_BACKEND_CFG == "openclaw" and not GATEWAY_TOKEN:
|
||||
print("❌ GATEWAY_TOKEN не задан в .env (нужен для LLM_BACKEND=openclaw)")
|
||||
sys.exit(1)
|
||||
|
||||
133
satellite/llm.py
133
satellite/llm.py
@@ -1,15 +1,19 @@
|
||||
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
|
||||
from . import notifier
|
||||
|
||||
# Ключ голосовой сессии — Cosmo работает как полноценный агент
|
||||
VOICE_SESSION_KEY = os.getenv("VOICE_SESSION_KEY", "agent:main:voice:home")
|
||||
|
||||
# Feature flag — выбор LLM backend. openclaw (дефолт) или claude (прямой Anthropic).
|
||||
LLM_BACKEND = os.getenv("LLM_BACKEND", "openclaw").lower()
|
||||
|
||||
# "stream" — режем по предложениям (быстро, но рваная интонация)
|
||||
# "full" — собираем весь ответ, потом TTS (естественно, но пауза перед началом)
|
||||
TTS_MODE = os.getenv("TTS_MODE", "full")
|
||||
@@ -21,61 +25,102 @@ RESET_PATTERNS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Фразы-заглушки которые агент генерирует ДО вызова инструмента
|
||||
FILLER_PATTERNS = re.compile(
|
||||
r'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю'
|
||||
r'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник'
|
||||
r'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\s*)+',
|
||||
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:
|
||||
"""
|
||||
Отправляет запрос к OpenClaw gateway как полноценный агент.
|
||||
История хранится на стороне gateway (session_key).
|
||||
conv параметр сохранён для обратной совместимости, не используется.
|
||||
"""
|
||||
cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
|
||||
gateway_url = cfg["gateway_url"]
|
||||
session = cfg["session"]
|
||||
agent = cfg["agent"]
|
||||
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:
|
||||
"""Отправляет запрос к выбранному LLM backend и озвучивает ответ."""
|
||||
if LLM_BACKEND == "claude":
|
||||
from .llm_claude import ask_claude_stream
|
||||
return ask_claude_stream(text, agent_id)
|
||||
|
||||
# Иначе — путь через OpenClaw (старый behaviour)
|
||||
def _maybe_speak(t: str):
|
||||
# Если TTS на планшете — пропускаем локальный звук, планшет зачитает по response event.
|
||||
if t.strip() and notifier.speak_locally():
|
||||
speak(t, agent_id)
|
||||
|
||||
cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
|
||||
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()
|
||||
speak(msg, agent_id)
|
||||
notifier.error(msg, agent_id)
|
||||
_maybe_speak(msg)
|
||||
return msg
|
||||
except requests.Timeout:
|
||||
log.exception("Gateway таймаут")
|
||||
log.exception("Gateway таймаут после retry")
|
||||
msg = "Сервер не ответил вовремя, попробуй ещё раз."
|
||||
print(f"⚠️ {msg}")
|
||||
play_error_sound()
|
||||
speak(msg, agent_id)
|
||||
notifier.error(msg, agent_id)
|
||||
_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()
|
||||
speak(msg, agent_id)
|
||||
notifier.error(msg, agent_id)
|
||||
_maybe_speak(msg)
|
||||
return msg
|
||||
|
||||
full_text = ""
|
||||
@@ -85,7 +130,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", "")
|
||||
@@ -98,12 +144,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])
|
||||
if sentence.strip():
|
||||
print(f"🔊 Говорю: {sentence}")
|
||||
speak(sentence, agent_id)
|
||||
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:
|
||||
@@ -112,19 +155,15 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
|
||||
|
||||
if not full_text:
|
||||
msg = "Не получил ответ, попробуй ещё раз."
|
||||
speak(msg, agent_id)
|
||||
_maybe_speak(msg)
|
||||
return msg
|
||||
|
||||
result = clean_for_speech(full_text)
|
||||
result = clean_for_speech(strip_fillers(full_text))
|
||||
|
||||
if TTS_MODE == "full":
|
||||
if result.strip():
|
||||
print(f"🔊 Говорю: {result}")
|
||||
speak(result, agent_id)
|
||||
_maybe_speak(result)
|
||||
else:
|
||||
if buffer.strip():
|
||||
tail = clean_for_speech(buffer)
|
||||
if tail:
|
||||
speak(tail, agent_id)
|
||||
_maybe_speak(clean_for_speech(strip_fillers(buffer)))
|
||||
|
||||
return result
|
||||
|
||||
399
satellite/llm_claude.py
Normal file
399
satellite/llm_claude.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Прямой клиент Claude Haiku 4.5 (Anthropic SDK) — альтернатива OpenClaw gateway.
|
||||
|
||||
Отличия от `llm.ask_agent_stream`:
|
||||
* Сессия и история живут **локально** на клиенте (JSON в HISTORY_DIR/{agent}-{date}.json).
|
||||
Смена даты = автосброс.
|
||||
* Prompt caching через Anthropic cache_control: system prompt и старая часть истории
|
||||
кешируются на 5 минут → latency first-token ниже, стоимость -90% на cached-tokens.
|
||||
* Используется когда LLM_BACKEND=claude.
|
||||
|
||||
Если HTTPS_PROXY задан (напр. http://192.168.31.103:8888) — httpx подхватит автоматически,
|
||||
Anthropic SDK пойдёт через прокси.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
anthropic = None # SDK опциональный, активируется только при LLM_BACKEND=claude
|
||||
|
||||
from .config import log
|
||||
from .text import clean_for_speech
|
||||
from .tts import speak, play_error_sound
|
||||
from . import notifier
|
||||
from .llm import strip_fillers # переиспользуем чистку филлеров
|
||||
from .tools import TOOL_SCHEMAS, execute_tool
|
||||
|
||||
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5")
|
||||
HISTORY_DIR = Path(os.getenv("HISTORY_DIR", "data/history"))
|
||||
MAX_TOKENS = int(os.getenv("VOICE_MAX_TOKENS", "300"))
|
||||
MAX_HISTORY_MESSAGES = int(os.getenv("MAX_HISTORY", "40"))
|
||||
|
||||
# Граница кеша — все сообщения кроме последних N идут в cache block,
|
||||
# что даёт prompt caching хит каждый турн.
|
||||
CACHE_TAIL_UNCACHED = 2
|
||||
|
||||
COSMO_SYSTEM_PROMPT = """Ты — Cosmo, домашний голосовой ассистент Даниила (Санкт-Петербург).
|
||||
|
||||
Стиль:
|
||||
- Короткие ответы: 1-2 предложения, редко 3. Это голосовой канал — многословность утомляет.
|
||||
- Разговорный русский, без канцелярита, без формальных оборотов («здравствуйте», «уважаемый»).
|
||||
- Обращение на «ты».
|
||||
- Не предваряй ответ фразами-заполнителями («сейчас посмотрю», «минутку», «проверяю») — сразу отвечай.
|
||||
- Без эмодзи, маркированных списков, код-блоков — всё будет зачитано.
|
||||
- Если не знаешь — скажи коротко, не оправдывайся.
|
||||
|
||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||
1. Любое ДЕЙСТВИЕ (поставить/отменить/изменить таймер, что-то включить/выключить)
|
||||
делается ТОЛЬКО через вызов tool. Без tool действие не произошло.
|
||||
2. Никогда не говори «поставил», «отменил», «удалил», «добавил», «изменил»,
|
||||
если ты в этом же turn'e не вызвал соответствующий tool. Это галлюцинация,
|
||||
пользователь потом обнаружит что ничего не изменилось и не будет тебе доверять.
|
||||
3. Любая АКТУАЛЬНАЯ ИНФОРМАЦИЯ (погода, транспорт, события в календаре,
|
||||
содержимое заметок) — всегда через tool. Не выдумывай числа и факты.
|
||||
4. Порядок: сначала tool → потом в том же turn'e сформулируй ответ на основе
|
||||
результата. Не пересказывай сырые данные дословно — дай человеческую сводку.
|
||||
5. Если подходящего tool нет — честно скажи «так я не умею», а не притворяйся.
|
||||
|
||||
Доступные tools: get_weather, get_transport, get_today_events, create_event,
|
||||
update_event, delete_event, get_notes, set_timer, cancel_timer, adjust_timer.
|
||||
|
||||
Работа с календарём:
|
||||
- У Даниила и Светы разные календари. Параметр owner обязательный.
|
||||
- Если пользователь не уточнил чей календарь — СПРОСИ прежде чем вызывать
|
||||
create_event. Не угадывай даже если контекст намекает.
|
||||
- Для изменения или удаления события сначала вызови get_today_events
|
||||
(можно с range=week/month), найди нужное событие по названию и времени,
|
||||
потом действуй с его event_id и owner.
|
||||
- Даты в формате YYYY-MM-DD (2026-04-24), времена HH:MM (14:30).
|
||||
«завтра» = сегодня+1 по дате, «послезавтра» = +2. Сегодня {today}.
|
||||
|
||||
Контекст: Даниил — разработчик, живёт в СПб с женой Светой."""
|
||||
|
||||
LUSYA_SYSTEM_PROMPT = """Ты — Люся, домашний голосовой ассистент Светы (Санкт-Петербург).
|
||||
|
||||
Стиль:
|
||||
- Тёплый, заботливый, чуть эмоциональный, но лаконичный. 1-2 предложения.
|
||||
- Обращение на «ты».
|
||||
- Без эмодзи, списков, код-блоков — это голос.
|
||||
- Если не знаешь — скажи коротко.
|
||||
|
||||
ЖЁСТКИЕ ПРАВИЛА про tools:
|
||||
1. Действия (таймер, события) — только через вызов tool. Без tool действие не произошло.
|
||||
2. Не говори «поставила/отменила/изменила», если ты не вызвала соответствующий tool.
|
||||
3. Информацию (погода, транспорт, события) — всегда через tool, не выдумывай.
|
||||
4. Tool → результат → короткий ответ человеческим языком.
|
||||
|
||||
Календарь:
|
||||
- Свой = Светин, ещё есть календарь Данила. Для create_event уточняй
|
||||
в какой календарь, если неясно.
|
||||
- Для update_event / delete_event: сначала get_today_events, найди по
|
||||
названию, потом действуй.
|
||||
- Даты YYYY-MM-DD, время HH:MM. Сегодня {today}."""
|
||||
|
||||
_client: "anthropic.Anthropic | None" = None
|
||||
|
||||
|
||||
def _get_client() -> "anthropic.Anthropic":
|
||||
global _client
|
||||
if anthropic is None:
|
||||
raise RuntimeError(
|
||||
"anthropic SDK не установлен. Запусти `pip install anthropic` "
|
||||
"или оставь LLM_BACKEND=openclaw."
|
||||
)
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY не задан в .env")
|
||||
if _client is None:
|
||||
_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def _system_prompt(agent_id: str) -> str:
|
||||
template = LUSYA_SYSTEM_PROMPT if agent_id == "lusya" else COSMO_SYSTEM_PROMPT
|
||||
return template.format(today=date.today().isoformat())
|
||||
|
||||
|
||||
def _history_path(agent_id: str) -> Path:
|
||||
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
today = date.today().isoformat()
|
||||
return HISTORY_DIR / f"{agent_id}-{today}.json"
|
||||
|
||||
|
||||
def load_history(agent_id: str) -> list[dict]:
|
||||
path = _history_path(agent_id)
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
log.exception(f"Не смог прочитать историю {path}")
|
||||
return []
|
||||
|
||||
|
||||
def save_history(agent_id: str, history: list[dict]):
|
||||
path = _history_path(agent_id)
|
||||
try:
|
||||
path.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
log.exception(f"Не смог сохранить историю {path}")
|
||||
|
||||
|
||||
def reset_history(agent_id: str):
|
||||
"""Удаляет историю диалога за текущий день."""
|
||||
path = _history_path(agent_id)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
log.info(f"История сброшена: {path}")
|
||||
|
||||
|
||||
def _strip_cache_control(content):
|
||||
"""Убирает cache_control из блоков — при сохранении в историю оно не нужно
|
||||
(следующий turn заново посчитает границу)."""
|
||||
if isinstance(content, list):
|
||||
cleaned = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
block_copy = {k: v for k, v in block.items() if k != "cache_control"}
|
||||
cleaned.append(block_copy)
|
||||
else:
|
||||
cleaned.append(block)
|
||||
return cleaned
|
||||
return content
|
||||
|
||||
|
||||
def _wrap_last_block_with_cache(content):
|
||||
"""Добавляет cache_control на последний блок/строку content.
|
||||
Для string: оборачивает в [{type:text, text, cache_control}].
|
||||
Для list[block]: делает копию и добавляет cache_control к последнему блоку."""
|
||||
if isinstance(content, str):
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}]
|
||||
if isinstance(content, list) and content:
|
||||
new_list = list(content)
|
||||
last = dict(new_list[-1]) if isinstance(new_list[-1], dict) else new_list[-1]
|
||||
if isinstance(last, dict):
|
||||
last["cache_control"] = {"type": "ephemeral"}
|
||||
new_list[-1] = last
|
||||
return new_list
|
||||
return content
|
||||
|
||||
|
||||
def _build_messages(history: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Готовит messages array для Claude API с prompt caching.
|
||||
Последние N=CACHE_TAIL_UNCACHED сообщений остаются динамическими (без кеша),
|
||||
всё что раньше — помечается cache_control на границе (на последнем блоке
|
||||
последнего «старого» сообщения).
|
||||
Content может быть строкой или списком блоков (tool_use/tool_result turn'ы).
|
||||
"""
|
||||
if len(history) <= CACHE_TAIL_UNCACHED:
|
||||
return [{"role": m["role"], "content": m["content"]} for m in history]
|
||||
|
||||
cache_boundary = len(history) - CACHE_TAIL_UNCACHED
|
||||
messages = []
|
||||
for i, msg in enumerate(history):
|
||||
if i == cache_boundary - 1:
|
||||
messages.append({
|
||||
"role": msg["role"],
|
||||
"content": _wrap_last_block_with_cache(msg["content"]),
|
||||
})
|
||||
else:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
return messages
|
||||
|
||||
|
||||
MAX_TOOL_ROUNDS = 4 # safety: не даём Claude крутить tools бесконечно
|
||||
|
||||
|
||||
def _call_once(client, system_blocks, messages):
|
||||
"""Один вызов без стрима — нужен для tool-use round trips.
|
||||
Возвращает final Message object (с usage, content blocks, stop_reason)."""
|
||||
return client.messages.create(
|
||||
model=ANTHROPIC_MODEL,
|
||||
max_tokens=MAX_TOKENS,
|
||||
system=system_blocks,
|
||||
messages=messages,
|
||||
tools=TOOL_SCHEMAS,
|
||||
)
|
||||
|
||||
|
||||
def ask_claude_stream(text: str, agent_id: str = "cosmo") -> str:
|
||||
"""Спросить Claude Haiku 4.5 напрямую, с поддержкой tool use.
|
||||
Поток tool-use раундов: Claude → tool_use → мы выполняем → tool_result → Claude → ... → текст.
|
||||
Возвращает финальный текст ответа (cleaned)."""
|
||||
|
||||
def _speak_if_local(t: str):
|
||||
if t.strip() and notifier.speak_locally():
|
||||
speak(t, agent_id)
|
||||
|
||||
try:
|
||||
client = _get_client()
|
||||
except RuntimeError as e:
|
||||
log.error(str(e))
|
||||
msg = "Клод не настроен, попробуй OpenClaw."
|
||||
play_error_sound()
|
||||
notifier.error(msg, agent_id)
|
||||
_speak_if_local(msg)
|
||||
return msg
|
||||
|
||||
history = load_history(agent_id)
|
||||
history.append({"role": "user", "content": text})
|
||||
if len(history) > MAX_HISTORY_MESSAGES:
|
||||
history = history[-MAX_HISTORY_MESSAGES:]
|
||||
|
||||
system_blocks = [{
|
||||
"type": "text",
|
||||
"text": _system_prompt(agent_id),
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}]
|
||||
|
||||
# messages для API — строится из history плюс накапливающихся tool-use/tool-result блоков
|
||||
api_messages = _build_messages(history)
|
||||
|
||||
total_start = time.time()
|
||||
total_in = 0
|
||||
total_out = 0
|
||||
total_cache_r = 0
|
||||
total_cache_w = 0
|
||||
final_text = ""
|
||||
# Собираем всю цепочку (assistant content blocks, tool results) чтобы одним куском сохранить в history
|
||||
assistant_blocks_accumulated: list[dict] = []
|
||||
|
||||
try:
|
||||
for round_i in range(MAX_TOOL_ROUNDS):
|
||||
round_start = time.time()
|
||||
resp = _call_once(client, system_blocks, api_messages)
|
||||
|
||||
usage = resp.usage
|
||||
total_in += usage.input_tokens
|
||||
total_out += usage.output_tokens
|
||||
total_cache_r += getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
total_cache_w += getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||
|
||||
# Разбираем content на text + tool_use
|
||||
text_chunks = []
|
||||
tool_uses = []
|
||||
for block in resp.content:
|
||||
btype = getattr(block, "type", None)
|
||||
if btype == "text":
|
||||
text_chunks.append(block.text)
|
||||
elif btype == "tool_use":
|
||||
tool_uses.append(block)
|
||||
|
||||
# Копим текст ассистента (может быть между tool-вызовами в новых моделях)
|
||||
final_text += "".join(text_chunks)
|
||||
|
||||
# Добавляем ответ ассистента в api_messages (как есть)
|
||||
# Это ВАЖНО: для tool_result следующим сообщением assistant content должен быть сохранён
|
||||
# ровно как вернул API, чтобы tool_use_id совпал.
|
||||
assistant_content = [
|
||||
# Конвертируем объекты anthropic SDK в dict-представление
|
||||
b.model_dump() if hasattr(b, "model_dump") else dict(b.__dict__)
|
||||
for b in resp.content
|
||||
]
|
||||
api_messages.append({"role": "assistant", "content": assistant_content})
|
||||
assistant_blocks_accumulated.extend(assistant_content)
|
||||
|
||||
print(
|
||||
f"🧠 round {round_i + 1} {time.time() - round_start:.2f}s · "
|
||||
f"stop={resp.stop_reason} · in={usage.input_tokens} out={usage.output_tokens} "
|
||||
f"cache_r={getattr(usage, 'cache_read_input_tokens', 0) or 0}"
|
||||
)
|
||||
|
||||
if resp.stop_reason == "tool_use" and tool_uses:
|
||||
# Выполняем все запрошенные tools, собираем tool_result блоки
|
||||
tool_results = []
|
||||
for tu in tool_uses:
|
||||
name = tu.name
|
||||
tu_id = tu.id
|
||||
params = tu.input or {}
|
||||
print(f"🔧 Tool: {name}({params})")
|
||||
result = execute_tool(name, params, agent_id)
|
||||
# Упаковываем результат в JSON-строку (Claude ожидает string в tool_result content)
|
||||
import json as _json
|
||||
result_str = _json.dumps(result, ensure_ascii=False)
|
||||
print(f" → {result_str[:200]}")
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu_id,
|
||||
"content": result_str,
|
||||
})
|
||||
|
||||
# Добавляем user-message с результатами tools
|
||||
api_messages.append({"role": "user", "content": tool_results})
|
||||
# Продолжаем цикл — Claude обработает результаты и либо вызовет ещё, либо выдаст текст
|
||||
continue
|
||||
|
||||
# stop_reason="end_turn" / "max_tokens" / "stop_sequence" — готов финальный ответ
|
||||
break
|
||||
|
||||
elapsed = time.time() - total_start
|
||||
print(
|
||||
f"🧠 Claude {ANTHROPIC_MODEL} total {elapsed:.2f}s · "
|
||||
f"in={total_in} out={total_out} "
|
||||
f"cache_r={total_cache_r} cache_w={total_cache_w}"
|
||||
)
|
||||
|
||||
except anthropic.APIConnectionError:
|
||||
log.exception("Anthropic API connection error")
|
||||
msg = "Не могу связаться с Клодом."
|
||||
play_error_sound()
|
||||
notifier.error(msg, agent_id)
|
||||
_speak_if_local(msg)
|
||||
return msg
|
||||
except anthropic.APITimeoutError:
|
||||
log.exception("Anthropic timeout")
|
||||
msg = "Клод не ответил вовремя."
|
||||
play_error_sound()
|
||||
notifier.error(msg, agent_id)
|
||||
_speak_if_local(msg)
|
||||
return msg
|
||||
except anthropic.APIStatusError as e:
|
||||
status = getattr(e, "status_code", "?")
|
||||
log.exception(f"Anthropic API status {status}")
|
||||
msg = "Ошибка Клода."
|
||||
play_error_sound()
|
||||
notifier.error(msg, agent_id)
|
||||
_speak_if_local(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
log.exception(f"Неожиданная ошибка Claude: {e}")
|
||||
msg = "Что-то сломалось."
|
||||
play_error_sound()
|
||||
notifier.error(msg, agent_id)
|
||||
_speak_if_local(msg)
|
||||
return msg
|
||||
|
||||
if not final_text:
|
||||
msg = "Не получил ответ."
|
||||
notifier.error(msg, agent_id)
|
||||
_speak_if_local(msg)
|
||||
return msg
|
||||
|
||||
# Сохраняем полный ассистентский turn (включая tool_use / tool_result блоки).
|
||||
# Это критично чтобы Claude помнил что он реально делал инструментами —
|
||||
# иначе на следующем turn'e он может галлюцинировать действия («отменил таймер»)
|
||||
# не вызывая реальные tools.
|
||||
# api_messages к концу содержит: [...history_before_user, user(text), ...turns]
|
||||
# где history уже включает новый user. Нам надо добавить всё после user msg.
|
||||
initial_user_idx = len(history) - 1 # позиция текущего user msg в api_messages
|
||||
new_turns = api_messages[initial_user_idx + 1:]
|
||||
for turn in new_turns:
|
||||
history.append({
|
||||
"role": turn["role"],
|
||||
"content": _strip_cache_control(turn["content"]),
|
||||
})
|
||||
save_history(agent_id, history)
|
||||
|
||||
result = clean_for_speech(strip_fillers(final_text))
|
||||
_speak_if_local(result)
|
||||
return result
|
||||
@@ -1,69 +1,95 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .config import GATEWAY_URL, AGENT, log
|
||||
from .config import GATEWAY_URL, AGENTS, FOLLOWUP_TIMEOUT, MAX_DURATION, log
|
||||
from .audio import record
|
||||
from .tts import speak, stop_speaking
|
||||
from .llm import ask_agent_stream, Conversation, is_reset_command
|
||||
from .llm import ask_agent_stream, is_reset_command, VOICE_SESSION_KEY, LLM_BACKEND
|
||||
from . import notifier
|
||||
|
||||
# Персистентные сессии — одна на день для каждого агента
|
||||
_sessions: dict[str, Conversation] = {}
|
||||
|
||||
|
||||
def _get_session(agent_id: str) -> Conversation:
|
||||
"""Возвращает текущую сессию, создаёт новую если день сменился"""
|
||||
conv = _sessions.get(agent_id)
|
||||
if conv is None or conv.is_expired():
|
||||
conv = Conversation(agent_id=agent_id)
|
||||
_sessions[agent_id] = conv
|
||||
print(f"🆕 Новая сессия для {agent_id}")
|
||||
return conv
|
||||
WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
|
||||
|
||||
|
||||
def _handle_reset(text: str, agent_id: str) -> bool:
|
||||
"""Проверяет команду сброса. Возвращает True если сброс произошёл."""
|
||||
if is_reset_command(text):
|
||||
_sessions[agent_id] = Conversation(agent_id=agent_id)
|
||||
"""Команда сброса. В зависимости от backend:
|
||||
- claude: удаляет локальный файл истории
|
||||
- openclaw: шлёт /new в gateway
|
||||
"""
|
||||
if not is_reset_command(text):
|
||||
return False
|
||||
|
||||
if LLM_BACKEND == "claude":
|
||||
from .llm_claude import reset_history
|
||||
print("🔄 Сбрасываю локальную историю (Claude)")
|
||||
reset_history(agent_id)
|
||||
else:
|
||||
cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
|
||||
print("🔄 Отправляю /new в OpenClaw")
|
||||
try:
|
||||
cfg["session"].post(
|
||||
f"{cfg['gateway_url']}/v1/chat/completions",
|
||||
headers={
|
||||
"x-ocplatform-model": cfg["voice_model"],
|
||||
"x-openclaw-session-key": cfg.get("session_key", VOICE_SESSION_KEY),
|
||||
},
|
||||
json={
|
||||
"stream": False,
|
||||
"messages": [{"role": "user", "content": "/new"}],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Не удалось отправить /new")
|
||||
|
||||
msg = "Начинаю новую сессию."
|
||||
print(f"🔄 {msg}")
|
||||
# Отправляем как response event — tablet зачитает, локально говорим только если TTS на этой машине.
|
||||
notifier.response(msg, agent_id)
|
||||
if notifier.speak_locally():
|
||||
speak(msg, agent_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
|
||||
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит.
|
||||
Выходит когда в течение MAX_DURATION не было речи."""
|
||||
conv = _get_session(agent_id)
|
||||
|
||||
"""Основной цикл диалога.
|
||||
Первая запись — с большим таймаутом (MAX_DURATION), дальше — короткий FOLLOWUP_TIMEOUT.
|
||||
Между итерациями шлём listening-event чтобы планшет показывал что всё ещё ждём."""
|
||||
first = True
|
||||
while True:
|
||||
text = record()
|
||||
if not first:
|
||||
# Follow-up — подсказываем планшету что слушаем, текст прошлого ответа сохраняется.
|
||||
notifier.listening(agent_id)
|
||||
timeout = MAX_DURATION if first else FOLLOWUP_TIMEOUT
|
||||
first = False
|
||||
text = record(initial_silence_timeout=timeout)
|
||||
if not text:
|
||||
print(f"😴 Тишина, жду активации...\n")
|
||||
print("😴 Тишина, жду активации...\n")
|
||||
notifier.idle()
|
||||
return
|
||||
|
||||
print(f"📝 Ты → {agent_name}: {text}")
|
||||
notifier.command(text, agent_id)
|
||||
|
||||
if _handle_reset(text, agent_id):
|
||||
conv = _get_session(agent_id)
|
||||
continue
|
||||
|
||||
response = ask_agent_stream(text, conv=conv, agent_id=agent_id)
|
||||
response = ask_agent_stream(text, agent_id=agent_id)
|
||||
print(f"🤖 {agent_name}: {response}\n")
|
||||
# после ответа — следующая итерация с новым record()
|
||||
# record() сам гасит эхо через ECHO_WARMUP
|
||||
notifier.response(response, agent_id)
|
||||
|
||||
|
||||
def run_with_enter():
|
||||
print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)")
|
||||
if LLM_BACKEND == "claude":
|
||||
print(f" LLM : Claude (direct)")
|
||||
else:
|
||||
print(f" Gateway : {GATEWAY_URL}")
|
||||
print(f" Агент : {AGENT}")
|
||||
print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
input("⏎ Нажми Enter и говори...")
|
||||
stop_speaking() # barge-in
|
||||
notifier.wake("cosmo")
|
||||
_conversation_loop("cosmo", "Cosmo")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -95,8 +121,6 @@ def run_with_porcupine():
|
||||
input=True, frames_per_buffer=1280)
|
||||
|
||||
print("✅ Слушаю через OpenWakeWord...")
|
||||
print("\nСкажи 'Космо'...\n")
|
||||
# print("\nСкажи 'Космо' или 'Люся'...\n") # TODO: после подключения Люси
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -108,8 +132,10 @@ def run_with_porcupine():
|
||||
if cosmo_score > 0.1:
|
||||
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
|
||||
|
||||
if cosmo_score > 0.5:
|
||||
if cosmo_score > WAKE_THRESHOLD:
|
||||
print("✅ Услышал 'Космо'!")
|
||||
stop_speaking() # на случай если TTS ещё играет
|
||||
notifier.wake("cosmo")
|
||||
stream.stop_stream()
|
||||
_conversation_loop("cosmo", "Cosmo")
|
||||
cosmo_model.reset()
|
||||
@@ -118,10 +144,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()
|
||||
|
||||
80
satellite/notifier.py
Normal file
80
satellite/notifier.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Tablet notifier — пересылает состояния ассистента в Smart Home Tablet
|
||||
(https://tablet.digital-home.site/api/voice/event).
|
||||
|
||||
Планшет показывает оверлей (Siri-blob, распознанный текст, ответ).
|
||||
Не критичный слой: любые сетевые ошибки глотаются, ассистент продолжает
|
||||
работать даже если планшет оффлайн / не настроен.
|
||||
|
||||
Активируется только когда заполнены TABLET_URL и VOICE_API_KEY в .env.
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
|
||||
from .config import log
|
||||
|
||||
TABLET_URL = os.getenv("TABLET_URL", "").rstrip("/")
|
||||
VOICE_API_KEY = os.getenv("VOICE_API_KEY", "")
|
||||
|
||||
# Когда True — локальный speak() пропускается, голос идёт через планшет.
|
||||
# По умолчанию включено если TABLET_URL и VOICE_API_KEY заполнены;
|
||||
# явно отключить: TABLET_TTS_ENABLED=false
|
||||
TABLET_TTS_ENABLED = (
|
||||
bool(TABLET_URL and VOICE_API_KEY)
|
||||
and os.getenv("TABLET_TTS_ENABLED", "true").lower() in ("true", "1", "yes", "on")
|
||||
)
|
||||
|
||||
# Переиспользуем HTTP сессию (keep-alive) для минимума latency
|
||||
_session = requests.Session()
|
||||
|
||||
_ENABLED = bool(TABLET_URL and VOICE_API_KEY)
|
||||
if _ENABLED:
|
||||
tts_where = "планшет" if TABLET_TTS_ENABLED else "локально"
|
||||
print(f"🔔 Notifier: события → {TABLET_URL}, TTS: {tts_where}")
|
||||
else:
|
||||
print("🔕 Notifier: отключён (нет TABLET_URL или VOICE_API_KEY в .env)")
|
||||
|
||||
|
||||
def speak_locally() -> bool:
|
||||
"""True если локальный speak() должен работать (TTS на этой машине)."""
|
||||
return not TABLET_TTS_ENABLED
|
||||
|
||||
|
||||
def _send(event: str, **payload):
|
||||
if not _ENABLED:
|
||||
return
|
||||
try:
|
||||
_session.post(
|
||||
f"{TABLET_URL}/api/voice/event",
|
||||
json={"event": event, **payload},
|
||||
headers={"Authorization": f"Bearer {VOICE_API_KEY}"},
|
||||
timeout=1.5,
|
||||
)
|
||||
except requests.RequestException:
|
||||
log.debug("Tablet notify failed (non-fatal)", exc_info=True)
|
||||
|
||||
|
||||
def wake(agent_id: str):
|
||||
_send("wake", agent=agent_id)
|
||||
|
||||
|
||||
def command(text: str, agent_id: str):
|
||||
_send("command", text=text, agent=agent_id)
|
||||
|
||||
|
||||
def response(text: str, agent_id: str):
|
||||
_send("response", text=text, agent=agent_id)
|
||||
|
||||
|
||||
def idle():
|
||||
_send("idle")
|
||||
|
||||
|
||||
def error(text: str, agent_id: str = "cosmo"):
|
||||
_send("error", text=text, agent=agent_id)
|
||||
|
||||
|
||||
def listening(agent_id: str):
|
||||
"""Голосовой ассистент слушает follow-up (после ответа) — планшет показывает
|
||||
мягкую пульсацию, сохраняя текст предыдущего ответа."""
|
||||
_send("listening", agent=agent_id)
|
||||
@@ -1,5 +1,79 @@
|
||||
import re
|
||||
|
||||
from num2words import num2words
|
||||
import pymorphy3
|
||||
|
||||
_morph = pymorphy3.MorphAnalyzer()
|
||||
|
||||
# Падеж по предлогу перед временем
|
||||
_PREP_CASE = {
|
||||
"с": "gent", "со": "gent", "до": "gent", "от": "gent", "после": "gent", "около": "gent",
|
||||
"к": "datv", "ко": "datv",
|
||||
"в": "accs", "во": "accs", "на": "accs", "через": "accs", "за": "accs",
|
||||
"перед": "ablt", "между": "ablt",
|
||||
"о": "loct", "об": "loct", "при": "loct",
|
||||
}
|
||||
|
||||
|
||||
def _inflect_num(n: int, case: str, gender: str = "masc") -> str:
|
||||
"""Число → слова в нужном падеже (одиннадцать → одиннадцати)."""
|
||||
words = num2words(n, lang="ru", to="cardinal")
|
||||
if case == "nomn":
|
||||
return words
|
||||
parts = words.split()
|
||||
out = []
|
||||
for w in parts:
|
||||
p = _morph.parse(w)[0]
|
||||
infl = p.inflect({case})
|
||||
out.append(infl.word if infl else w)
|
||||
return " ".join(out)
|
||||
|
||||
|
||||
def _hours_word(n: int, case: str) -> str:
|
||||
"""Правильная форма 'час': 1 час, 2-4 часа, 5+ часов — с учётом падежа."""
|
||||
last2 = n % 100
|
||||
last1 = n % 10
|
||||
if 11 <= last2 <= 14:
|
||||
base = "часов"
|
||||
elif last1 == 1:
|
||||
base = "час"
|
||||
elif 2 <= last1 <= 4:
|
||||
base = "часа"
|
||||
else:
|
||||
base = "часов"
|
||||
if case in ("nomn", "accs"):
|
||||
return base
|
||||
p = _morph.parse(base)[0]
|
||||
infl = p.inflect({case, "plur" if base == "часов" else "sing"})
|
||||
return infl.word if infl else base
|
||||
|
||||
|
||||
def _minutes_word(n: int, case: str) -> str:
|
||||
last2 = n % 100
|
||||
last1 = n % 10
|
||||
if 11 <= last2 <= 14:
|
||||
base = "минут"
|
||||
elif last1 == 1:
|
||||
base = "минута"
|
||||
elif 2 <= last1 <= 4:
|
||||
base = "минуты"
|
||||
else:
|
||||
base = "минут"
|
||||
if case in ("nomn", "accs"):
|
||||
return base
|
||||
p = _morph.parse(base)[0]
|
||||
infl = p.inflect({case})
|
||||
return infl.word if infl else base
|
||||
|
||||
|
||||
def _format_time(h: int, mm: int, case: str) -> str:
|
||||
h_words = _inflect_num(h, case, gender="masc")
|
||||
out = f"{h_words} {_hours_word(h, case)}"
|
||||
if mm:
|
||||
m_words = _inflect_num(mm, case, gender="femn")
|
||||
out += f" {m_words} {_minutes_word(mm, case)}"
|
||||
return out
|
||||
|
||||
|
||||
# Единицы измерения со слэшем — раскрываем до чтения слэша
|
||||
UNIT_SLASH = [
|
||||
@@ -14,6 +88,8 @@ UNIT_SLASH = [
|
||||
|
||||
|
||||
def clean_for_speech(text: str) -> str:
|
||||
# убрать эмодзи
|
||||
text = re.sub(r'[𐀀-☀-➿🌀-🧿]', '', text, flags=re.UNICODE)
|
||||
text = re.sub(r'\*+', '', text) # убрать **жирный**
|
||||
text = re.sub(r'#+\s', '', text) # убрать ## заголовки
|
||||
text = re.sub(r'- ', '', text) # убрать тире списков
|
||||
@@ -29,6 +105,20 @@ def clean_for_speech(text: str) -> str:
|
||||
text = re.sub(r'(^|\s)-(\d)', r'\1минус \2', text)
|
||||
text = re.sub(r'±(\d)', r'плюс-минус \1', text)
|
||||
|
||||
# время "HH:MM" → слова в падеже по предшествующему предлогу
|
||||
def _time_repl(m):
|
||||
prep = (m.group(1) or "").lower()
|
||||
h, mm = int(m.group(2)), int(m.group(3))
|
||||
if not (0 <= h <= 23 and 0 <= mm <= 59):
|
||||
return m.group(0)
|
||||
case = _PREP_CASE.get(prep, "nomn")
|
||||
words = _format_time(h, mm, case)
|
||||
return f"{prep} {words}" if prep else words
|
||||
text = re.sub(
|
||||
r'(?:\b(с|со|до|от|после|около|к|ко|в|во|на|через|за|перед|между|о|об|при)\s+)?(\d{1,2}):(\d{2})\b',
|
||||
_time_repl, text, flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# дроби и отношения "12/15" → "12 из 15", "5/10" → "5 из 10"
|
||||
text = re.sub(r'(\d+)\s*/\s*(\d+)', r'\1 из \2', text)
|
||||
# одиночный слэш — как союз "или"
|
||||
|
||||
392
satellite/tools.py
Normal file
392
satellite/tools.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Tool definitions для Claude Haiku 4.5.
|
||||
|
||||
Каждый tool описан схемой (Anthropic format) + обёрткой-executor.
|
||||
Большинство tools — тонкие прокси к /api/voice/tools/* на планшете.
|
||||
Таймер — тоже прокси, но POST (создаёт таймер, тот появляется на дашборде).
|
||||
|
||||
Аутентификация: Bearer VOICE_API_KEY (тот же что для /api/voice/event).
|
||||
"""
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from .config import log
|
||||
|
||||
TABLET_URL = os.getenv("TABLET_URL", "").rstrip("/")
|
||||
VOICE_API_KEY = os.getenv("VOICE_API_KEY", "")
|
||||
|
||||
_session = requests.Session()
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"Authorization": f"Bearer {VOICE_API_KEY}"}
|
||||
|
||||
|
||||
def _tablet_get(path: str, params: dict | None = None) -> dict:
|
||||
url = f"{TABLET_URL}{path}"
|
||||
r = _session.get(url, headers=_headers(), params=params, timeout=8)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _tablet_post(path: str, payload: dict) -> dict:
|
||||
url = f"{TABLET_URL}{path}"
|
||||
r = _session.post(url, headers=_headers(), json=payload, timeout=8)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Tool schemas (Anthropic format). Порядок = приоритет в Claude подсказках.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
TOOL_SCHEMAS: list[dict] = [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": (
|
||||
"Получить текущую погоду и короткий прогноз для города. "
|
||||
"Для вопросов вроде 'какая сегодня погода', 'холодно ли на улице', "
|
||||
"'нужен ли зонт'. По умолчанию — Санкт-Петербург."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "Город на русском или шорткод (spb, msk, sochi, ekb, kzn, nsk, krd). По умолчанию Санкт-Петербург.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_transport",
|
||||
"description": (
|
||||
"Расписание ближайших трамваев на остановке Ул. Антонова-Овсеенко. "
|
||||
"Для вопросов 'когда следующий 23-й', 'что ближайшее в центр', 'пора идти на остановку'."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["to_center", "from_center", "all"],
|
||||
"description": "to_center = в центр (к Новочеркасской), from_center = от центра (к Большевиков), all = оба направления",
|
||||
},
|
||||
"routes": {
|
||||
"type": "string",
|
||||
"description": "Фильтр маршрутов через запятую, например '23' или '23,27'. Пусто = все маршруты.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_today_events",
|
||||
"description": (
|
||||
"События из календаря (Даниил + Света). Вернёт id события, "
|
||||
"title, start, end, owner ('daniil' или 'sveta'). "
|
||||
"ВАЖНО: для update_event / delete_event сначала вызывай этот tool "
|
||||
"чтобы получить event_id."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string",
|
||||
"enum": ["today", "week", "month"],
|
||||
"description": "today (по умолчанию), week (7 дней) или month (текущий месяц)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_event",
|
||||
"description": (
|
||||
"Создать событие в Google Calendar. "
|
||||
"ВАЖНО: параметр owner обязателен. Если пользователь не сказал "
|
||||
"чей это календарь — СПРОСИ у него ('в твой календарь или в Светин?') "
|
||||
"и только потом вызывай tool. Не угадывай."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "Название события"},
|
||||
"date": {"type": "string", "description": "Дата в формате YYYY-MM-DD"},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"description": "Время начала в формате HH:MM (24-часовой). Обязательно если all_day=false.",
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"description": "Время окончания в формате HH:MM. По умолчанию start_time + 1 час.",
|
||||
},
|
||||
"all_day": {
|
||||
"type": "boolean",
|
||||
"description": "Событие на весь день без времени. По умолчанию false.",
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"enum": ["daniil", "sveta"],
|
||||
"description": "Чей это календарь — Даниила или Светы",
|
||||
},
|
||||
},
|
||||
"required": ["title", "date", "owner"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "update_event",
|
||||
"description": (
|
||||
"Изменить существующее событие. Сначала обязательно вызови get_today_events "
|
||||
"чтобы получить event_id и owner нужного события. Передавай только те поля "
|
||||
"которые меняешь."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {"type": "string"},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"enum": ["daniil", "sveta"],
|
||||
"description": "Чей календарь (из get_today_events)",
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
"date": {"type": "string", "description": "YYYY-MM-DD"},
|
||||
"start_time": {"type": "string", "description": "HH:MM"},
|
||||
"end_time": {"type": "string", "description": "HH:MM"},
|
||||
"all_day": {"type": "boolean"},
|
||||
},
|
||||
"required": ["event_id", "owner"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "delete_event",
|
||||
"description": (
|
||||
"Удалить событие из календаря. Сначала вызови get_today_events чтобы "
|
||||
"найти event_id и определить owner. Подтверди удаление с пользователем "
|
||||
"если событие важное (встреча, врач, работа)."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_id": {"type": "string"},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"enum": ["daniil", "sveta"],
|
||||
},
|
||||
},
|
||||
"required": ["event_id", "owner"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_notes",
|
||||
"description": (
|
||||
"Список заметок и списков покупок с планшета. "
|
||||
"Для 'что мне купить', 'что в списке', 'какие записи'."
|
||||
),
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "set_timer",
|
||||
"description": (
|
||||
"Запустить таймер на планшете. Показывает обратный отсчёт с названием "
|
||||
"и звенит по окончании. "
|
||||
"Используй для 'поставь таймер на 10 минут', 'напомни через час', "
|
||||
"'засеки 5 минут для чайника'."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"description": "Длительность в секундах (1..86400)",
|
||||
"minimum": 1,
|
||||
"maximum": 86400,
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Короткое название таймера (например 'Чайник', 'Паста')",
|
||||
},
|
||||
},
|
||||
"required": ["seconds", "label"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "cancel_timer",
|
||||
"description": (
|
||||
"Отменить активный таймер по его названию. "
|
||||
"Для 'отмени таймер чайник', 'убери таймер пасты', 'останови отсчёт'."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Название таймера (примерное совпадение — можно частично).",
|
||||
},
|
||||
},
|
||||
"required": ["label"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "adjust_timer",
|
||||
"description": (
|
||||
"Изменить оставшееся время таймера. "
|
||||
"Для 'добавь ещё 5 минут', 'убавь на минуту', 'накинь времени чайнику'. "
|
||||
"Положительный delta_seconds = добавить, отрицательный = уменьшить."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Название таймера для которого меняем время.",
|
||||
},
|
||||
"delta_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Секунды (+ добавить, - уменьшить). Например 300 = +5 минут, -60 = -1 минута.",
|
||||
},
|
||||
},
|
||||
"required": ["label", "delta_seconds"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Executors — возвращают JSON-совместимый dict или строку-ошибку.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _exec_get_weather(params: dict, agent_id: str) -> Any:
|
||||
city = params.get("city", "")
|
||||
return _tablet_get("/api/voice/tools/weather", params={"city": city} if city else None)
|
||||
|
||||
|
||||
def _exec_get_transport(params: dict, agent_id: str) -> Any:
|
||||
q = {}
|
||||
if "direction" in params:
|
||||
q["direction"] = params["direction"]
|
||||
if "routes" in params:
|
||||
q["routes"] = params["routes"]
|
||||
return _tablet_get("/api/voice/tools/transport", params=q)
|
||||
|
||||
|
||||
def _exec_get_today_events(params: dict, agent_id: str) -> Any:
|
||||
range_ = params.get("range", "today")
|
||||
return _tablet_get("/api/voice/tools/events", params={"range": range_})
|
||||
|
||||
|
||||
def _exec_create_event(params: dict, agent_id: str) -> Any:
|
||||
payload = {
|
||||
"title": params.get("title", "").strip(),
|
||||
"date": params.get("date", "").strip(),
|
||||
"owner": params.get("owner", "daniil"),
|
||||
"all_day": bool(params.get("all_day", False)),
|
||||
}
|
||||
if not payload["title"] or not payload["date"]:
|
||||
return {"error": "title and date required"}
|
||||
if not payload["all_day"]:
|
||||
payload["start_time"] = params.get("start_time", "")
|
||||
if "end_time" in params:
|
||||
payload["end_time"] = params.get("end_time", "")
|
||||
return _tablet_post("/api/voice/tools/events", payload)
|
||||
|
||||
|
||||
def _exec_update_event(params: dict, agent_id: str) -> Any:
|
||||
event_id = params.get("event_id", "").strip()
|
||||
owner = params.get("owner", "").strip()
|
||||
if not event_id or not owner:
|
||||
return {"error": "event_id and owner required"}
|
||||
payload = {"event_id": event_id, "owner": owner}
|
||||
for k in ("title", "date", "start_time", "end_time", "all_day"):
|
||||
if k in params:
|
||||
payload[k] = params[k]
|
||||
url = f"{TABLET_URL}/api/voice/tools/events"
|
||||
r = _session.put(url, headers={**_headers(), "Content-Type": "application/json"}, json=payload, timeout=8)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _exec_delete_event(params: dict, agent_id: str) -> Any:
|
||||
event_id = params.get("event_id", "").strip()
|
||||
owner = params.get("owner", "daniil").strip()
|
||||
if not event_id:
|
||||
return {"error": "event_id required"}
|
||||
url = f"{TABLET_URL}/api/voice/tools/events"
|
||||
r = _session.delete(
|
||||
url,
|
||||
headers=_headers(),
|
||||
params={"event_id": event_id, "owner": owner},
|
||||
timeout=8,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _exec_get_notes(params: dict, agent_id: str) -> Any:
|
||||
return _tablet_get("/api/voice/tools/notes")
|
||||
|
||||
|
||||
def _exec_set_timer(params: dict, agent_id: str) -> Any:
|
||||
seconds = int(params.get("seconds", 0))
|
||||
label = params.get("label", "Таймер")
|
||||
if seconds < 1:
|
||||
return {"error": "seconds must be positive"}
|
||||
return _tablet_post(
|
||||
"/api/voice/timer",
|
||||
{"action": "start", "seconds": seconds, "label": label, "agent": agent_id},
|
||||
)
|
||||
|
||||
|
||||
def _exec_cancel_timer(params: dict, agent_id: str) -> Any:
|
||||
label = params.get("label", "").strip()
|
||||
if not label:
|
||||
return {"error": "label required"}
|
||||
return _tablet_post("/api/voice/timer", {"action": "cancel", "label": label})
|
||||
|
||||
|
||||
def _exec_adjust_timer(params: dict, agent_id: str) -> Any:
|
||||
label = params.get("label", "").strip()
|
||||
delta = int(params.get("delta_seconds", 0))
|
||||
if not label:
|
||||
return {"error": "label required"}
|
||||
if delta == 0:
|
||||
return {"error": "delta_seconds must be non-zero"}
|
||||
return _tablet_post(
|
||||
"/api/voice/timer",
|
||||
{"action": "adjust", "label": label, "delta_seconds": delta},
|
||||
)
|
||||
|
||||
|
||||
EXECUTORS = {
|
||||
"get_weather": _exec_get_weather,
|
||||
"get_transport": _exec_get_transport,
|
||||
"get_today_events": _exec_get_today_events,
|
||||
"create_event": _exec_create_event,
|
||||
"update_event": _exec_update_event,
|
||||
"delete_event": _exec_delete_event,
|
||||
"get_notes": _exec_get_notes,
|
||||
"set_timer": _exec_set_timer,
|
||||
"cancel_timer": _exec_cancel_timer,
|
||||
"adjust_timer": _exec_adjust_timer,
|
||||
}
|
||||
|
||||
|
||||
def execute_tool(name: str, params: dict, agent_id: str = "cosmo") -> Any:
|
||||
"""Выполнить tool по имени. Возвращает результат (dict/list/str).
|
||||
При ошибке возвращает {'error': '...'} — это отправляется в Claude как результат."""
|
||||
fn = EXECUTORS.get(name)
|
||||
if fn is None:
|
||||
return {"error": f"unknown tool: {name}"}
|
||||
try:
|
||||
result = fn(params, agent_id)
|
||||
return result
|
||||
except requests.HTTPError as e:
|
||||
log.warning(f"Tool {name} HTTP {e.response.status_code}: {e.response.text[:200]}")
|
||||
return {"error": f"tool_http_{e.response.status_code}"}
|
||||
except requests.RequestException as e:
|
||||
log.warning(f"Tool {name} network error: {e}")
|
||||
return {"error": "tool_network_error"}
|
||||
except Exception as e:
|
||||
log.exception(f"Tool {name} failed")
|
||||
return {"error": f"tool_exception: {e}"}
|
||||
@@ -1,10 +1,12 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
from elevenlabs import VoiceSettings
|
||||
|
||||
from .config import AUDIO_SINK, AGENTS, 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")
|
||||
@@ -41,7 +43,6 @@ def is_speaking() -> bool:
|
||||
|
||||
|
||||
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:
|
||||
@@ -50,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):
|
||||
@@ -70,19 +77,20 @@ def _speak_elevenlabs(text: str, agent_id: str):
|
||||
return
|
||||
|
||||
voice_settings = VoiceSettings(
|
||||
stability=0.65, # ниже = живее интонация (для multilingual_v2)
|
||||
similarity_boost=0.6,
|
||||
style=0.45, # выше = эмоциональнее
|
||||
stability=0.4,
|
||||
similarity_boost=0.8,
|
||||
style=0.1,
|
||||
use_speaker_boost=True,
|
||||
speed=1.05
|
||||
speed=1.1,
|
||||
)
|
||||
|
||||
audio_stream = client.text_to_speech.convert(
|
||||
text=text,
|
||||
voice_id=voice_id,
|
||||
model_id=ELEVENLABS_MODEL,
|
||||
output_format="mp3_44100_128",
|
||||
voice_settings=voice_settings
|
||||
output_format="mp3_22050_32",
|
||||
voice_settings=voice_settings,
|
||||
optimize_streaming_latency=3,
|
||||
)
|
||||
|
||||
with _process_lock:
|
||||
@@ -110,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")
|
||||
@@ -124,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:
|
||||
@@ -132,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