23 Commits

Author SHA1 Message Date
Cosmo
52c42f3d06 feat(tools): calendar CRUD tools — create_event, update_event, delete_event
- create_event(title, date, start_time?, end_time?, all_day?, owner)
  owner обязателен (daniil | sveta). System prompt велит LLM уточнять
  чей это календарь, если неясно.
- update_event(event_id, owner, ...fields) — меняет только переданные
  поля. Сначала нужно вызвать get_today_events для получения event_id.
- delete_event(event_id, owner) — сначала get_today_events, найти
  событие по названию, подтвердить если важное.

get_today_events теперь возвращает event_id и owner (daniil/sveta),
плюс принимает range=month. Description явно говорит LLM что это
первый tool для CRUD-сценариев.

System prompt (Cosmo и Люся) дополнен секцией 'Работа с календарём'
с правилами: даты YYYY-MM-DD, время HH:MM, «завтра» = +1 день,
вычислять от {today}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:34:40 +00:00
Cosmo
f530607503 fix(llm_claude): store tool turns in history + stricter prompt
Bug: Claude hallucinated actions. User said «удалить таймер чайника»,
Claude replied «Таймер чайника отменён» без вызова cancel_timer.

Две причины:
1) История сохраняла только финальный текст предыдущих turn'ов.
   Claude видел «я говорил поставил таймер» и мог ответить «удалил» по
   паттерну без реального tool-use.
2) System prompt мягко просил использовать tools — Haiku иногда
   пропускал tool и отвечал сразу.

Фикс:
- История теперь содержит полные turn'ы (assistant с tool_use блоками,
  user с tool_result блоками). _build_messages/_strip_cache_control
  корректно обрабатывают content как string или list of blocks.
- System prompt добавил жёсткий раздел «ЖЁСТКИЕ ПРАВИЛА про tools»:
  явно запрещено говорить 'поставил/отменил/удалил' без вызова tool,
  информацию (погода, события) — только через tool, не выдумывать.

Размер истории вырастет (tool_result'ы могут быть по 500-2000 байт),
но это не проблема — prompt caching делает каждый turn дешёвым на
чтение (cache_r > 90% в логах).
2026-04-23 14:04:27 +00:00
Cosmo
c7df540c0b feat(voice): emit listening event between followup turns
После ответа Python сразу уходит в record() ждать follow-up
(FOLLOWUP_TIMEOUT), но планшет об этом не знал — оверлей тихо
скрывался и пользователю казалось что Cosmo его не слышит без
повторного wake-word.

Теперь между итерациями _conversation_loop шлётся notifier.listening() —
планшет показывает мягко пульсирующий орб с 'жду' + сохранённым
текстом прошлого ответа. Закрывается только по notifier.idle()
(таймаут тишины) или если пользователь что-то сказал (command).
2026-04-23 13:55:39 +00:00
Cosmo
d1f95669e0 feat(tools): cancel_timer + adjust_timer
Two new Claude tools for voice control over existing timers.
Both accept a {label} (fuzzy match on tablet side, case-insensitive
substring) so the LLM doesn'\''t need to know internal timer ids.

- cancel_timer(label) → POST /api/voice/timer {action:cancel}
- adjust_timer(label, delta_seconds) → POST {action:adjust}

Use cases:
  '\''Отмени таймер чайник'\'' → cancel_timer(label="чайник")
  '\''Добавь ещё 5 минут к пасте'\'' → adjust_timer(label="паста", delta_seconds=300)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:51:31 +00:00
Cosmo
5a2d34d268 feat(claude): tool use — weather, transport, events, notes, timer
Claude Haiku 4.5 теперь умеет дёргать tools. Все tools — proxy к endpoints
планшета (/api/voice/tools/* и /api/voice/timer) с bearer auth
VOICE_API_KEY. Никакой дополнительной auth в скрипте не требуется.

- satellite/tools.py — 5 tools:
  * get_weather(city?)            → Open-Meteo через tablet
  * get_transport(direction, routes?) → трамваи Антонова-Овсеенко
  * get_today_events(range?)      → Google Calendar (today/week)
  * get_notes()                   → текстовые + shopping lists
  * set_timer(seconds, label)     → создаёт таймер на дашборде
  Каждый tool возвращает dict/list; ошибки упаковываются как {error: ...}
  и отдаются Claude как результат — он сам обрабатывает.

- satellite/llm_claude.py:
  * Подключил TOOL_SCHEMAS в вызов messages.create
  * Цикл tool-use: до MAX_TOOL_ROUNDS=4 раундов tool_use → exec → tool_result
  * System prompt дополнен инструкцией «используй tools без спроса»
  * Финальный текст (после всех tool rounds) сохраняется в историю как один
    assistant-turn — tool rounds в history не пишутся чтобы не раздувать кеш
  * Usage логируется суммарно за все раунды

Работает с уже поднятым tinyproxy на .103 (HTTPS_PROXY в .env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:33:51 +00:00
Cosmo
356543afdb config: make OpenClaw credentials optional when LLM_BACKEND=claude
Allows removing all GATEWAY_*, VOICE_MODEL, *_SESSION_KEY env vars
when running on the Claude direct backend. The OpenClaw hard-exit
check now only fires when that backend is actually selected.
2026-04-23 13:18:23 +00:00
Cosmo
05de9c284b feat(llm): direct Claude Haiku 4.5 backend with prompt caching
Adds a parallel LLM backend that bypasses OpenClaw and talks to
Anthropic Messages API directly. Selected via LLM_BACKEND=claude in
.env; default remains openclaw so nothing breaks for existing setup.

Why: OpenClaw gateway adds 500-1000ms overhead on every turn (auth,
memory fetch, routing). Direct Haiku 4.5 + prompt caching = faster
first token and -90% cost on cached chunks.

- satellite/llm_claude.py — Anthropic SDK streaming client, prompt
  caching on system prompt and all-but-last-2 history messages, per
  agent+date JSON history in HISTORY_DIR, reset_history() for the
  'сбрось' command, per-agent system prompts (Cosmo / Люся), fallback
  to error event if SDK/key missing.
- satellite/llm.py — dispatches to ask_claude_stream when backend=claude,
  exports LLM_BACKEND so modes.py can route reset too.
- satellite/modes.py — _handle_reset calls reset_history when backend
  is claude, keeps /new POST for openclaw.
- requirements.txt — anthropic >= 0.50.0
- .env.example — LLM_BACKEND, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
  HISTORY_DIR, MAX_HISTORY, HTTPS_PROXY block for non-RU egress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:12:39 +00:00
Cosmo
584e21923c feat(notifier): route TTS to tablet when TABLET_TTS_ENABLED
When TABLET_URL and VOICE_API_KEY are set, the tablet handles TTS
via its ElevenLabs proxy — local speak() is skipped. Controlled
by TABLET_TTS_ENABLED (default true when tablet is configured).

- notifier.speak_locally() — gate used by all local speech paths
- llm._maybe_speak — no-op when tablet plays the voice
- modes._handle_reset — emits response event and skips local speak
  when tablet TTS is on; keeps spoken fallback otherwise

Tablet side in smart-home-tablet repo: /api/voice/tts endpoint +
VoiceOverlay audio playback (commit ba2e… pending).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:52:34 +00:00
Cosmo
e4e7529063 feat(notifier): push state events to Smart Home Tablet overlay
Adds a thin HTTP bridge so the tablet at https://tablet.digital-home.site
shows a Siri-style overlay reflecting the current assistant state
(wake / command / response / idle / error). Non-fatal: if the tablet
is offline or TABLET_URL/VOICE_API_KEY are unset, events are silently
skipped and the assistant keeps working.

- satellite/notifier.py — POST /api/voice/event with bearer token,
  reused requests.Session for keep-alive, 1.5s timeout
- satellite/modes.py — emits wake on activation, command after STT,
  response after LLM, idle on timeout
- satellite/llm.py — emits error on gateway connection/timeout/HTTP
- .env.example documents TABLET_URL and VOICE_API_KEY

Tablet side (separate repo smart-home-tablet, commit 51c3d60) exposes
POST /api/voice/event + GET /api/voice/stream (SSE) and renders a
full-screen overlay in components/VoiceOverlay.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:43:01 +00:00
a9001aef92 refactor: VAD upgrade, retry, dead code cleanup, AGENT removal
- audio: switch VAD to webrtcvad with RMS gate + fallback to RMS
- audio: honor FOLLOWUP_TIMEOUT — short silence wait after bot response
- llm: retry with exponential backoff on network errors and 5xx
- llm: VOICE_MAX_TOKENS env (default 300) instead of hardcoded 150
- tts: optional VAD-based barge-in (BARGE_IN_ENABLED, off by default)
- tts: remove dead start_barge_in_listener / was_barge_in helpers
- config: drop AGENT/LUSYA_AGENT — routing happens via session_key
- modes: remove unused imports, pass FOLLOWUP_TIMEOUT to follow-up record()
- docs: full rewrite of README and CLAUDE.md to match current architecture
2026-04-16 17:10:59 +03:00
Cosmo
a885cbe74b feat: VAD-based barge-in during TTS playback 2026-04-14 15:28:14 +00:00
Cosmo
cdf8748e48 feat: VAD-based barge-in during TTS playback 2026-04-14 15:28:12 +00:00
Cosmo
cd921e1540 fix: strip emoji from TTS text in clean_for_speech 2026-04-14 15:26:21 +00:00
3301b3559d Merge branch 'main' of https://git.digital-home.site/daniil/home-voice-assistant 2026-04-14 18:23:41 +03:00
0f4ae3a80c Edit new session 2026-04-14 18:22:01 +03:00
Cosmo
cc9de661cc feat: barge-in support — stop TTS when wake word detected during playback 2026-04-14 15:02:00 +00:00
Cosmo
182e7875ab fix: strip filler phrases from agent response before TTS 2026-04-14 11:49:58 +00:00
a0618c961d Add russian translate 2026-04-14 14:40:52 +03:00
cc8cbefe18 Delete logs 2026-04-14 13:45:38 +03:00
Cosmo
24c8e38be6 fix: replace VOICE_SESSION_KEY with COSMO_SESSION_KEY and LUSYA_SESSION_KEY 2026-04-14 09:45:48 +00:00
09d22177cd Edit voice settings 2026-04-13 23:28:41 +03:00
0494c24c47 Delete conversation from modes 2026-04-13 23:19:18 +03:00
28cccbdac1 Merge pull request 'feat: route voice through OpenClaw agent session (full memory + tools)' (#1) from feature/openclaw-agent-session into main
Reviewed-on: #1
2026-04-13 20:15:02 +00:00
13 changed files with 1644 additions and 367 deletions

View File

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

249
CLAUDE.md
View File

@@ -1,85 +1,83 @@
# Cosmo Voice Satellite
Голосовой ассистент дома — аналог Алисы через OpenClaw. Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway.
Голосовой ассистент дома — аналог Алисы, но поверх LLM (через OpenClaw Gateway). Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway.
## Архитектура
```
┌─────────────┐ wake word ┌──────────────┐ STT (Groq)
│ Microphone │ ───────────► │ Satellite │ ──────────────►
└─────────────┘ └──────────────┘ │
┌──────────────┐
OpenClaw
Gateway
│ │ (N100 PC)
stream response └──────────────┘
┌─────────────┐ wake word ┌──────────────┐ STT (Groq)
│ Microphone │ ───────────► │ Satellite │ ──────────────► OpenClaw Gateway
└─────────────┘ │ (Pi 5 / │ (N100, Proxmox)
Mac) │ ◄── LLM stream ──
└──────────────┘
▼ TTS текст
ElevenLabs stream (mp3)
▼ │
┌──────────────┐ │
│ ElevenLabs │ ◄─────────────────┘
│ TTS │
└──────────────┘
▼ mp3 stream
┌──────────────┐
│ mpv │ → speakers (BT)
└──────────────┘
mpv (stdin) → speakers (BT/aux) ◄──┘
```
Сессия диалога теперь **на стороне OpenClaw** — satellite отправляет лишь `x-openclaw-session-key`, а история и память живут в gateway. Клиент stateless.
## Инфраструктура
- **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox
- **Cosmo Gateway**: порт `18789`, агент `openclaw/main`
- **Люся Gateway**: порт `18790`, агент `openclaw/wife`
- **Модель**: `openai/gpt-5.4-mini` (через `x-openclaw-model` header)
- **Cosmo Gateway**: порт `18789`, агент `openclaw/main`, session_key `agent:voice:voice:home`
- **Люся Gateway**: порт `18790`, агент `openclaw/wife`, session_key `agent:wife:voice:home`
- **Модель**: `openai/gpt-5.4-mini` (через `x-ocplatform-model` header; переопределяется через `VOICE_MODEL`)
- **STT**: Groq API, `whisper-large-v3-turbo`, язык ru
- **TTS**: ElevenLabs, `eleven_flash_v2_5` (~75ms латентность)
- **Wake word**: Porcupine (на Pi), Enter (при разработке)
- **TTS**: ElevenLabs (`eleven_flash_v2_5` / `eleven_turbo_v2_5` / `eleven_multilingual_v2` — выбирается через `ELEVENLABS_MODEL`)
- **Wake word**: openwakeword (`.onnx`, обучается на своих голосах через `training/step_*.py`). Раньше закладывали Porcupine — отказались.
## Структура проекта
```
home-voice-assistant/
├── .env # секреты (не в git)
├── .env.example # шаблон
├── .env # секреты (не в git)
├── .env.example # шаблон
├── requirements.txt
├── satellite.py # обёртка для запуска
├── satellite.py # обёртка для запуска
├── satellite/
│ ├── __init__.py
│ ├── __main__.py # entry: python -m satellite [--wake]
│ ├── config.py # env, AGENTS dict, keep-alive sessions
│ ├── text.py # clean_for_speech, find_sentence_end
│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла)
│ ├── audio.py # record, record_with_timeout (VAD)
│ ├── tts.py # ElevenLabs streaming через mpv, barge-in
── llm.py # ask_agent_stream, Conversation (history)
│ └── modes.py # run_with_enter, run_with_porcupine
│ ├── __main__.py # entry: python -m satellite [--wake]
│ ├── config.py # env, AGENTS dict, keep-alive sessions
│ ├── text.py # clean_for_speech (+ pymorphy3/num2words для времени)
│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла)
│ ├── audio.py # record (RMS VAD)
│ ├── tts.py # ElevenLabs streaming через mpv stdin
│ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
── modes.py # run_with_enter / run_with_porcupine + /new через slash
├── record_wav.py # запись обучающих wav-ов для wake word
├── remove_silent.py # чистка тихих записей
├── training/ # пайплайн обучения wake word (не в git)
└── deploy/
├── setup.sh # установка на Raspberry Pi
└── cosmo-satellite.service # systemd unit
└── cosmo-satellite.service # systemd unit
```
## Что важно знать
## Ключевые инварианты
### Сессии диалога
- **Одна сессия на день** для каждого агента. Это осознанное решение: каждая новая сессия в OpenClaw тяжёлая (чтение памяти, большой контекст).
- История хранится в `Conversation.messages[]` на клиенте и отправляется целиком с каждым запросом (stateless к серверу).
- Сброс сессии: фраза "начни новую сессию" / "сбрось историю" / "очисти контекст" — паттерны в `RESET_PATTERNS` в `llm.py`.
- Автосброс при смене даты (`Conversation.is_expired()`).
- `MAX_HISTORY=20` — лимит сообщений, чтобы не раздувать контекст.
- История и контекст **на стороне OpenClaw**. Клиент шлёт только текущий user-message + `x-openclaw-session-key`.
- Сброс: фраза «начни новую сессию» / «сбрось историю» / «очисти контекст» (паттерны в `llm.py::RESET_PATTERNS`) → `modes._handle_reset` делает прямой POST с `/new`.
- Автосброс по таймауту **пока не реализован** (кандидат Этапа 1).
### Оптимизации скорости (все уже внедрены)
1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py._make_session()`, переиспользуется TCP/TLS.
### Оптимизации скорости
1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py::_make_session()`, переиспользует TCP/TLS.
2. **Streaming TTS** — ElevenLabs аудио пайпится в `mpv` через stdin, играет пока генерируется.
3. **STT без диска** — PCM → WAV в `BytesIO` → Groq, без temp файлов.
4. **Barge-in**`stop_speaking()` вызывается при каждой активации, убивает текущий mpv процесс.
3. **STT без диска** — PCM → WAV в `BytesIO` → Groq.
4. **Низкокачественный mp3 для речи** (`mp3_22050_32`) — меньше латентность без заметной потери качества для голоса.
5. **`optimize_streaming_latency=3`** в ElevenLabs convert — выдаёт первый чанк быстрее.
### Роутинг по wake word
В `modes.py::run_with_porcupine` Porcupine грузит оба wake word:
- index 0 = Cosmo `AGENTS["cosmo"]` (:18789)
- index 1 = Люся`AGENTS["lusya"]` (:18790)
`modes.py::run_with_porcupine` сейчас грузит только модель Cosmo. Код Люси закомментирован до того, как модель обучена. Когда готова:
- index 0 → `AGENTS["cosmo"]` (:18789)
- index 1 → `AGENTS["lusya"]` (:18790)
Каждый агент имеет свой `tts_voice` в ElevenLabs.
### Нормализация речи перед TTS
Два слоя защиты:
1. `text.py::clean_for_speech` — regex-правила: эмодзи, `**жирный**`, числа с плюсом/минусом, проценты, слэши, градусы, аббревиатуры (`т.е.`, `т.к.`), UNIT_SLASH (км/ч → «километров в час»), время `HH:MM` → прописью в правильном падеже через pymorphy3 + num2words.
2. `llm.py::strip_fillers` — режет фразы-заглушки («сейчас посмотрю», «дай секунду») которые агент генерит перед вызовом tool.
### Ошибки не должны ронять сервис
Каждый слой (stt, tts, llm, audio, modes) ловит `Exception` и пишет в `errors.log` через `config.log`. Верхний уровень в modes.py ловит всё непредвиденное и продолжает цикл.
@@ -88,103 +86,126 @@ home-voice-assistant/
### macOS / Windows (разработка)
```bash
python -m venv .venv
# macOS/Linux: source .venv/bin/activate
# Windows: .venv\Scripts\activate
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # заполнить ключи
python satellite.py # режим Enter (без wake word)
python satellite.py --wake # режим Porcupine (нужны .ppn + PORCUPINE_KEY)
python satellite.py --wake # режим openwakeword (нужна обученная .onnx)
```
### Raspberry Pi (продакшн)
```bash
sudo bash deploy/setup.sh
# далее:
sudo systemctl start cosmo-satellite
sudo journalctl -u cosmo-satellite -f
```
## Зависимости системы
- **Python 3.12+**
- **portaudio** — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`)
- **mpv** — для воспроизведения TTS (`brew install mpv` / `apt install mpv`)
- **ffmpeg** — опционально, для совместимости форматов
- Python 3.12+
- `portaudio` — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`)
- `mpv` — для воспроизведения TTS (`brew install mpv` / `apt install mpv`)
### Windows
- Python 3.12+ с pip
- `pip install pyaudio` — обычно работает через колеса pipwin или pre-built wheels. Если нет — `pip install pipwin && pipwin install pyaudio`
- mpv: скачать с [mpv.io](https://mpv.io/installation/), положить `mpv.exe` в PATH
- Porcupine работает и на Windows — wake word модель нужна под платформу windows (качать отдельную `.ppn`)
## Переменные окружения
Все в `.env`. Ключевые:
## Переменные окружения (ключевые)
| Переменная | Что |
|-----------|-----|
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway на N100 |
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Токены авторизации |
| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw (`openclaw/main`, `openclaw/wife`) |
| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | Модель LLM для голоса |
|---|---|
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway |
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены |
| `AGENT`, `LUSYA_AGENT` | Имя агента (`openclaw/main`, `openclaw/wife`) |
| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) |
| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии OpenClaw |
| `GROQ_API_KEY` | Groq для STT |
| `ELEVENLABS_API_KEY` | ElevenLabs TTS |
| `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL` | TTS |
| `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID в ElevenLabs |
| `ELEVENLABS_MODEL` | `eleven_flash_v2_5` (быстрый) |
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто. |
| `PORCUPINE_KEY`, `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Только для `--wake` режима |
| `SILENCE_THRESHOLD=500` | VAD: чувствительность (ниже = ловит тихую речь) |
| `SILENCE_DURATION=1.5` | Сек тишины = конец фразы |
| `FOLLOWUP_TIMEOUT=8` | Сек ожидания продолжения диалога |
| `MAX_HISTORY=20` | Макс. сообщений в сессии |
| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям wake word |
| `WAKE_THRESHOLD` | Порог активации (0..1, дефолт 0.5) |
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто |
| `SILENCE_THRESHOLD`, `SILENCE_DURATION`, `MAX_DURATION`, `FOLLOWUP_TIMEOUT` | VAD |
| `TTS_MODE` | `full` (целостная интонация) или `stream` (быстрый старт, рваный) |
| `ECHO_WARMUP` | Сек пропуска в начале записи (гасит эхо от TTS) |
## Частые задачи
**Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID берётся на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library).
**Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library). Для русского лучше native-русские голоса, а не мультиязычные (не будет англоязычного акцента).
**Отладить VAD (ассистент не слышит / слушает слишком долго)**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек).
**Отладить VAD**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек).
**Добавить третьего агента**: в `config.py::AGENTS` новый ключ, в `modes.py::run_with_porcupine` добавить `WAKE_WORD_*` и `wake_word_map.append(...)`.
**Добавить третьего агента**: в `config.py::AGENTS` новый ключ + `WAKE_WORD_*` + раскомментировать блок в `modes.py::run_with_porcupine`.
**Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-openclaw-model`. Модель `openclaw/main` остаётся как agent (это маршрут в OpenClaw).
**Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-ocplatform-model`. Поле `model` в JSON остаётся `openclaw/main` (это имя агента, а не LLM).
**Добавить фразу-заглушку**: в `llm.py::FILLER_PATTERNS` дополнить список. Эти фразы режутся из ответа перед TTS — агент генерит их до tool-call.
## Что НЕ делать
- Не комитить `.env` (есть в `.gitignore`)
- Не возвращать fallback на macOS `say` — проект специально унифицирован на ElevenLabs + mpv
- Не создавать новую сессию Conversation на каждую активацию — это было в старой версии, сейчас одна сессия на день
- Не добавлять temp файлы для WAV/mp3 — всё идёт через `BytesIO` / stdin pipe
- Не возвращать `say`/`espeak` — проект унифицирован на ElevenLabs + mpv
- Не хранить историю диалога на клиенте — это делает OpenClaw по `session_key`
- Не создавать temp файлы для WAV/mp3 — всё через `BytesIO` / stdin pipe
- Не включать `style>0` и `speed≠1.0` в VoiceSettings — усиливают «иностранный» акцент и ломают просодию
## Тренировка своего wake word
## Тренировка wake word
Пайплайн в `training/`:
- `record_wav.py <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** — при потере связи ассистент молчит до явной ошибки.

242
README.md
View File

@@ -1,74 +1,87 @@
# Cosmo Voice Satellite
Домашний голосовой ассистент. Слушает wake word, распознаёт речь, ходит в OpenClaw gateway, проигрывает ответ через ElevenLabs.
Домашний голосовой ассистент поверх LLM — аналог Алисы/Siri, но умнее, потому что за ним стоит полноценный агент OpenClaw с памятью, tools и инструментами.
Два агента: **Cosmo** (владельца) и **Люся** (жены) — каждый со своим wake word и своим gateway.
Два агента **Cosmo** (владельца) и **Люся** (жены). Каждый со своим wake word, своим голосом ElevenLabs и своим OpenClaw gateway.
## Архитектура
```
mic ─► wake word (openwakeword) ─► STT (Groq) ─► OpenClaw gateway ─► TTS (ElevenLabs) ─► mpv ─► speakers
mic ─► wake word (openwakeword)
└► STT (Groq whisper) ─► OpenClaw Gateway (session_key, N100) ─► LLM
▼ streamed text
ElevenLabs TTS ─► mpv stdin ─► speakers
```
- **Wake word:** openwakeword (обучается на своих записях, см. ниже). Раньше планировался Porcupine — отказались.
- **STT:** Groq API, `whisper-large-v3-turbo`, ru.
- **LLM:** OpenClaw gateway на N100 (`192.168.31.103:18789` для cosmo, `:18790` для lusya), `openai/gpt-5.4-mini`.
- **TTS:** ElevenLabs `eleven_flash_v2_5` стримом через mpv stdin.
- **Wake word**: openwakeword (`.onnx`, обученная на своих записях)
- **STT**: Groq API, `whisper-large-v3-turbo`, ru
- **Агент**: OpenClaw на N100, модели через `x-ocplatform-model` header (`openai/gpt-5.4-mini` и т.п.)
- **История диалога**: на сервере OpenClaw (per `session_key`), клиент stateless
- **TTS**: ElevenLabs streaming через mpv
## Структура
```
home-voice-assistant/
├── satellite.py # entry-обёртка
├── satellite/ # рантайм
│ ├── __main__.py # python -m satellite [--wake]
│ ├── config.py, text.py
│ ├── stt.py, audio.py, tts.py, llm.py
── modes.py # run_with_enter / run_with_porcupine (wake word)
├── record_wav.py # запись датасета для wake word
├── remove_silent.py # чистка тихих + перенумерация
├── training/ # пайплайн обучения wake word
── step_1.py … step_5.py
│ ├── training_config.json
│ ├── own_samples/<word>/{positive,negative}/*.wav
│ ├── openwakeword/ # форк
│ └── my_custom_model/<word>/ # фичи + .onnx
── data/models/ # готовые .onnx wake word моделей
└── deploy/ # setup.sh + systemd unit для Pi
├── satellite.py # entry-обёртка
├── satellite/ # рантайм
│ ├── __main__.py # python -m satellite [--wake]
│ ├── config.py # AGENTS, keep-alive sessions
│ ├── audio.py # запись + RMS VAD
── stt.py # Groq whisper
│ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
│ ├── tts.py # ElevenLabs → mpv stdin
├── text.py # clean_for_speech (+ pymorphy3 для времени)
── modes.py # run_with_enter / run_with_porcupine
├── record_wav.py # запись датасета wake word
├── remove_silent.py # чистка тихих + перенумерация
├── training/ # openwakeword пайплайн (в .gitignore)
├── data/models/ # готовые .onnx wake word моделей
── deploy/ # setup.sh + systemd unit для Pi 5
```
## Запуск
## Быстрый старт
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # заполнить ключи
cp .env.example .env # заполнить ключи
python satellite.py # режим Enter (без wake word, для отладки)
python satellite.py --wake # режим wake word (нужна обученная модель в data/models/)
python satellite.py # режим Enter (отладка)
python satellite.py --wake # режим wake word (нужна модель в data/models/)
```
Системные зависимости:
- Python 3.12+
- `portaudio` `brew install portaudio`
- `mpv``brew install mpv`
### Системные зависимости
macOS: `brew install portaudio mpv`
Linux: `apt install portaudio19-dev mpv`
Windows: мpv с [mpv.io](https://mpv.io), `pip install pipwin && pipwin install pyaudio`
## Сессия диалога
История и память живут **на стороне OpenClaw**. Satellite отправляет только текущее сообщение + `x-openclaw-session-key`. Сервер сам подклеивает контекст.
Сброс сессии:
- Голосом: «начни новую сессию» / «сбрось историю» / «очисти контекст» → satellite шлёт slash-команду `/new` в OpenClaw
- Программно: меняй `COSMO_SESSION_KEY` в `.env`
## Обучение своего wake word
OpenWakeWord обучает DNN-модель на твоих записях слова. Пайплайн в `training/`:
OpenWakeWord тренирует DNN-модель на твоих записях слова. Пайплайн в `training/`:
| Шаг | Что делает |
|-----|-----------|
| `step_1.py` | Установка зависимостей (piper, openwakeword) |
| `step_2.py` | Создаёт `training_config.json` (параметры обучения) |
| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features) — ~17 GB |
| `step_2.py` | Создаёт `training_config.json` |
| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features, ~17 GB) |
| `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` |
| `step_5.py` | Проверка моделей и подсказки для `.env` |
### Запись датасета
```bash
# по одной записи (Enter → 2 секунды → сохраняем)
# по одной записи (Enter → 2 с → сохраняем)
python record_wav.py cosmo positive
python record_wav.py cosmo negative
@@ -77,7 +90,7 @@ python record_wav.py cosmo negative long 300
INPUT_DEVICE=1 python record_wav.py cosmo negative long 600
```
`record_wav.py` отбраковывает тихие записи по `MIN_RMS=300` (изменить можно константой в начале файла).
`record_wav.py` отбраковывает тихие записи по `MIN_RMS=300`.
### Чистка
@@ -89,85 +102,126 @@ python remove_silent.py
### Тренировка
1. В `training/training_config.json` укажи `wake_word_list`, `use_own_samples: true`, параметры:
```json
{
"wake_word_list": ["cosmo"],
"use_own_samples": true,
"false_activation_penalty": 100,
"target_false_positives_per_hour": 3.0,
"target_recall": 0.5,
"number_of_training_steps": 3000,
"layer_size": 64
}
```
2. В `training/openwakeword/examples/custom_model.yml` подними `augmentation_rounds: 10` (или больше).
3. Снеси кэш если был старый запуск:
```bash
rm -rf training/my_custom_model/<word> data/models/<word>.onnx
```
4. Запусти:
```bash
python training/step_4.py
```
5. Пропиши в `.env`: `WAKE_WORD_COSMO=data/models/cosmo.onnx`.
```bash
# в training/training_config.json:
# {
# "wake_word_list": ["cosmo"],
# "use_own_samples": true,
# "false_activation_penalty": 100,
# "target_false_positives_per_hour": 3.0,
# "target_recall": 0.5,
# "number_of_training_steps": 3000,
# "layer_size": 64
# }
rm -rf training/my_custom_model/cosmo data/models/cosmo.onnx
python training/step_4.py
# → .env: WAKE_WORD_COSMO=data/models/cosmo.onnx
```
### Сколько данных нужно
| Positive | Negative | Recall |
| Positive | Negative | Ожидаемый recall |
|---|---|---|
| 100200 | 200+ | 0.10.3 (плохо) |
| 300500 | 500+ | 0.40.6 (минимум) |
| 300500 | 500+ | 0.40.6 (минимум для работы) |
| 8001500 | 1000+ | 0.70.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 позитивов с разнообразием.

View File

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

View File

@@ -2,22 +2,59 @@ import os
import pyaudio
import numpy as np
from .config import SILENCE_THRESHOLD, SILENCE_DURATION, MAX_DURATION, log
from .config import (
SILENCE_THRESHOLD, SILENCE_DURATION, MAX_DURATION,
FOLLOWUP_TIMEOUT, VAD_AGGRESSIVENESS, log,
)
from .stt import transcribe
ECHO_WARMUP = float(os.getenv("ECHO_WARMUP", "0.5")) # сек пропуска в начале — гасит эхо от TTS
try:
import webrtcvad
_vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
_VAD_OK = True
except Exception as e:
log.warning(f"webrtcvad недоступен, fallback на RMS: {e}")
_vad = None
_VAD_OK = False
# webrtcvad требует фрейм 10/20/30 мс при 8/16/32/48 кГц
SAMPLE_RATE = 16000
FRAME_MS = 30
FRAME_SAMPLES = int(SAMPLE_RATE * FRAME_MS / 1000) # 480
FRAME_BYTES = FRAME_SAMPLES * 2 # int16
def _is_speech(frame: bytes) -> bool:
"""Единое решение по VAD: webrtcvad + RMS-гейт, чтобы не ловить шёпот и эхо."""
amplitude = float(np.abs(np.frombuffer(frame, dtype=np.int16)).mean())
if amplitude < SILENCE_THRESHOLD:
return False
if _VAD_OK:
try:
return _vad.is_speech(frame, SAMPLE_RATE)
except Exception:
pass
return True # RMS уже прошёл — считаем речью
def record(initial_silence_timeout: float | None = None) -> str:
"""Запись до тишины + STT.
initial_silence_timeout — через сколько секунд выйти если пользователь вообще не начал говорить.
По умолчанию FOLLOWUP_TIMEOUT (короткое ожидание после ответа бота).
"""
if initial_silence_timeout is None:
initial_silence_timeout = FOLLOWUP_TIMEOUT
def record() -> str:
"""Запись до тишины (VAD) + STT. Игнорирует ECHO_WARMUP в начале."""
try:
audio = pyaudio.PyAudio()
stream = audio.open(
format=pyaudio.paInt16,
channels=1,
rate=16000,
rate=SAMPLE_RATE,
input=True,
frames_per_buffer=1024,
frames_per_buffer=FRAME_SAMPLES,
)
except Exception as e:
log.exception("Не удалось открыть микрофон")
@@ -25,30 +62,38 @@ def record() -> str:
return ""
print("🎙️ Говори...")
frames = []
silent_chunks = 0
frames: list[bytes] = []
speaking_started = False
max_chunks = int(16000 / 1024 * MAX_DURATION)
silence_chunks_needed = int(16000 / 1024 * SILENCE_DURATION)
warmup_chunks = int(16000 / 1024 * ECHO_WARMUP)
trailing_silence = 0 # фреймы тишины после начала речи
initial_silence = 0 # фреймы тишины до начала речи
max_frames = int(MAX_DURATION * 1000 / FRAME_MS)
warmup_frames = int(ECHO_WARMUP * 1000 / FRAME_MS)
silence_frames_needed = int(SILENCE_DURATION * 1000 / FRAME_MS)
initial_silence_limit = int(initial_silence_timeout * 1000 / FRAME_MS)
try:
for i in range(max_chunks):
data = stream.read(1024, exception_on_overflow=False)
if i < warmup_chunks:
continue # гасим эхо от TTS / звука активации
for i in range(max_frames):
data = stream.read(FRAME_SAMPLES, exception_on_overflow=False)
if i < warmup_frames:
continue
frames.append(data)
amplitude = np.abs(np.frombuffer(data, dtype=np.int16)).mean()
if amplitude > SILENCE_THRESHOLD:
if _is_speech(data):
speaking_started = True
silent_chunks = 0
elif speaking_started:
silent_chunks += 1
if silent_chunks >= silence_chunks_needed:
print("🔇 Конец речи")
break
trailing_silence = 0
else:
if speaking_started:
trailing_silence += 1
if trailing_silence >= silence_frames_needed:
print("🔇 Конец речи")
break
else:
initial_silence += 1
if initial_silence >= initial_silence_limit:
print("😴 Пользователь молчит, выхожу")
speaking_started = False
break
except Exception as e:
log.exception("Ошибка при записи аудио")
print(f"⚠️ Ошибка записи: {e}")

View File

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

View File

@@ -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)
payload = {
"stream": True,
"messages": [{"role": "user", "content": text}],
"max_tokens": VOICE_MAX_TOKENS,
}
headers = {
"x-ocplatform-model": cfg["voice_model"],
"x-openclaw-session-key": session_key,
}
try:
resp = session.post(
f"{gateway_url}/v1/chat/completions",
headers={
"x-ocplatform-model": cfg["voice_model"],
"x-openclaw-session-key": session_key,
},
json={
"model": agent,
"stream": True,
"messages": [{"role": "user", "content": text}],
"max_tokens": 150,
},
stream=True,
timeout=60,
resp = _post_with_retry(
cfg["session"], f"{cfg['gateway_url']}/v1/chat/completions", headers, payload,
)
resp.raise_for_status()
except requests.ConnectionError:
log.exception("Gateway недоступен")
log.exception("Gateway недоступен после retry")
msg = "Не могу связаться с сервером, попробуй ещё раз."
print(f"⚠️ {msg}")
play_error_sound()
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,46 +130,40 @@ def ask_agent_stream(text: str, conv=None, agent_id: str = "cosmo") -> str:
for line in resp.iter_lines():
if not line or line == b"data: [DONE]":
continue
if line.startswith(b"data: "):
try:
chunk = json.loads(line[6:])
delta = chunk["choices"][0]["delta"].get("content", "")
if not delta:
continue
full_text += delta
buffer += delta
if TTS_MODE == "stream":
last_punct = find_sentence_end(buffer, min_len=120)
if last_punct > -1:
sentence = clean_for_speech(buffer[:last_punct + 1])
if sentence.strip():
print(f"🔊 Говорю: {sentence}")
speak(sentence, agent_id)
buffer = buffer[last_punct + 1:].lstrip()
except (json.JSONDecodeError, KeyError, IndexError):
if not line.startswith(b"data: "):
continue
try:
chunk = json.loads(line[6:])
delta = chunk["choices"][0]["delta"].get("content", "")
if not delta:
continue
full_text += delta
buffer += delta
if TTS_MODE == "stream":
last_punct = find_sentence_end(buffer, min_len=120)
if last_punct > -1:
sentence = clean_for_speech(strip_fillers(buffer[:last_punct + 1]))
_maybe_speak(sentence)
buffer = buffer[last_punct + 1:].lstrip()
except (json.JSONDecodeError, KeyError, IndexError):
continue
except Exception as e:
log.exception("Ошибка при чтении стрима")
print(f"⚠️ Стрим прервался: {e}")
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
View 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

View File

@@ -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)
msg = "Начинаю новую сессию."
print(f"🔄 {msg}")
"""Команда сброса. В зависимости от 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
return True
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 для активации)")
print(f" Gateway : {GATEWAY_URL}")
print(f" Агент : {AGENT}")
if LLM_BACKEND == "claude":
print(f" LLM : Claude (direct)")
else:
print(f" Gateway : {GATEWAY_URL}")
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
View 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)

View File

@@ -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
View 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}"}

View File

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