Compare commits

24 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
Cosmo
d9d892664a feat: route voice requests through OpenClaw agent session
- Remove local Conversation history (now managed by gateway)
- Use x-openclaw-session-key for persistent agent sessions
- Agent now has full context: SOUL.md, MEMORY.md, tools
- Add VOICE_SESSION_KEY env var (default: agent:main:voice:home)
- Backward compatible: conv parameter kept for compatibility
2026-04-13 20:12:01 +00:00
13 changed files with 1643 additions and 400 deletions

View File

@@ -1,13 +1,12 @@
# OpenClaw Gateway — Cosmo # OpenClaw Gateway — Cosmo
# Роутинг к агенту идёт через COSMO_SESSION_KEY, отдельный AGENT не нужен.
GATEWAY_URL=http://192.168.31.103:18789 GATEWAY_URL=http://192.168.31.103:18789
GATEWAY_TOKEN=your_openclaw_token_here GATEWAY_TOKEN=your_openclaw_token_here
AGENT=openclaw/main
VOICE_MODEL=openai/gpt-5.4-mini VOICE_MODEL=openai/gpt-5.4-mini
# OpenClaw Gateway — Люся # OpenClaw Gateway — Люся
LUSYA_GATEWAY_URL=http://192.168.31.103:18790 LUSYA_GATEWAY_URL=http://192.168.31.103:18790
LUSYA_GATEWAY_TOKEN=your_openclaw_token_here LUSYA_GATEWAY_TOKEN=your_openclaw_token_here
LUSYA_AGENT=openclaw/main
LUSYA_VOICE_MODEL=openai/gpt-5.4-mini LUSYA_VOICE_MODEL=openai/gpt-5.4-mini
# STT (Groq) # STT (Groq)
@@ -31,6 +30,51 @@ SILENCE_THRESHOLD=500
SILENCE_DURATION=1.5 SILENCE_DURATION=1.5
MAX_DURATION=15 MAX_DURATION=15
FOLLOWUP_TIMEOUT=8 FOLLOWUP_TIMEOUT=8
VAD_AGGRESSIVENESS=2 # webrtcvad 0..3, больше = строже
# LLM
VOICE_MAX_TOKENS=300
LLM_RETRIES=3
# Barge-in (прерывание TTS голосом). Работает только при разнесённых мике/колонке
# или в наушниках — иначе собственный TTS будет триггерить прерывание.
BARGE_IN_ENABLED=false
BARGE_IN_THRESHOLD=1500 # RMS выше SILENCE_THRESHOLD
BARGE_IN_WARMUP=0.8 # сек пропуска в начале TTS
# Логирование # Логирование
LOG_FILE=errors.log LOG_FILE=errors.log
COSMO_SESSION_KEY=agent:voice:voice:home
LUSYA_SESSION_KEY=agent:wife:voice:home
# Smart Home Tablet integration (опционально)
# Если настроено — скрипт шлёт события состояния (wake/command/response/idle/error)
# на планшет, который показывает оверлей с Siri-blob + распознанным текстом.
# Если не настроено, просто пропускается, ассистент работает как раньше.
TABLET_URL=https://tablet.digital-home.site
VOICE_API_KEY=your_voice_api_key_here
# TABLET_TTS_ENABLED=true (по умолчанию true когда TABLET_URL/KEY заданы) —
# голос ассистента проигрывается на планшете через ElevenLabs proxy,
# локальный mpv/speak пропускается. false = говорим локально как раньше.
TABLET_TTS_ENABLED=true
# ——————————————————————————————————————————————
# LLM backend
# openclaw (дефолт) — существующий путь через gateway с памятью на сервере
# claude — прямой вызов Anthropic Haiku 4.5 с локальной историей
# и prompt caching (быстрее + дешевле, но без tools)
LLM_BACKEND=openclaw
# Для LLM_BACKEND=claude:
ANTHROPIC_API_KEY=your_anthropic_key_here
ANTHROPIC_MODEL=claude-haiku-4-5
HISTORY_DIR=data/history # куда сохранять JSON истории per-agent per-date
MAX_HISTORY=40 # лимит сообщений в истории
# Egress proxy для non-RU сервисов (Anthropic, Groq, OpenAI).
# httpx и requests подхватывают автоматически. Пусто = прямой выход.
HTTPS_PROXY=http://192.168.31.103:8888
HTTP_PROXY=http://192.168.31.103:8888
NO_PROXY=localhost,127.0.0.1,192.168.31.0/24

233
CLAUDE.md
View File

@@ -1,40 +1,34 @@
# Cosmo Voice Satellite # Cosmo Voice Satellite
Голосовой ассистент дома — аналог Алисы через OpenClaw. Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway. Голосовой ассистент дома — аналог Алисы, но поверх LLM (через OpenClaw Gateway). Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway.
## Архитектура ## Архитектура
``` ```
┌─────────────┐ wake word ┌──────────────┐ STT (Groq) ┌─────────────┐ wake word ┌──────────────┐ STT (Groq)
│ Microphone │ ───────────► │ Satellite │ ──────────────► │ Microphone │ ───────────► │ Satellite │ ──────────────► OpenClaw Gateway
└─────────────┘ └──────────────┘ │ └─────────────┘ │ (Pi 5 / │ (N100, Proxmox)
Mac) │ ◄── LLM stream ──
┌──────────────┐ └──────────────┘
OpenClaw
Gateway ▼ TTS текст
│ │ (N100 PC) ElevenLabs stream (mp3)
stream response └──────────────┘
▼ │ ▼ │
┌──────────────┐ │ mpv (stdin) → speakers (BT/aux) ◄──┘
│ ElevenLabs │ ◄─────────────────┘
│ TTS │
└──────────────┘
▼ mp3 stream
┌──────────────┐
│ mpv │ → speakers (BT)
└──────────────┘
``` ```
Сессия диалога теперь **на стороне OpenClaw** — satellite отправляет лишь `x-openclaw-session-key`, а история и память живут в gateway. Клиент stateless.
## Инфраструктура ## Инфраструктура
- **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox - **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox
- **Cosmo Gateway**: порт `18789`, агент `openclaw/main` - **Cosmo Gateway**: порт `18789`, агент `openclaw/main`, session_key `agent:voice:voice:home`
- **Люся Gateway**: порт `18790`, агент `openclaw/wife` - **Люся Gateway**: порт `18790`, агент `openclaw/wife`, session_key `agent:wife:voice:home`
- **Модель**: `openai/gpt-5.4-mini` (через `x-openclaw-model` header) - **Модель**: `openai/gpt-5.4-mini` (через `x-ocplatform-model` header; переопределяется через `VOICE_MODEL`)
- **STT**: Groq API, `whisper-large-v3-turbo`, язык ru - **STT**: Groq API, `whisper-large-v3-turbo`, язык ru
- **TTS**: ElevenLabs, `eleven_flash_v2_5` (~75ms латентность) - **TTS**: ElevenLabs (`eleven_flash_v2_5` / `eleven_turbo_v2_5` / `eleven_multilingual_v2` — выбирается через `ELEVENLABS_MODEL`)
- **Wake word**: Porcupine (на Pi), Enter (при разработке) - **Wake word**: openwakeword (`.onnx`, обучается на своих голосах через `training/step_*.py`). Раньше закладывали Porcupine — отказались.
## Структура проекта ## Структура проекта
@@ -45,41 +39,45 @@ home-voice-assistant/
├── requirements.txt ├── requirements.txt
├── satellite.py # обёртка для запуска ├── satellite.py # обёртка для запуска
├── satellite/ ├── satellite/
│ ├── __init__.py
│ ├── __main__.py # entry: python -m satellite [--wake] │ ├── __main__.py # entry: python -m satellite [--wake]
│ ├── config.py # env, AGENTS dict, keep-alive sessions │ ├── config.py # env, AGENTS dict, keep-alive sessions
│ ├── text.py # clean_for_speech, find_sentence_end │ ├── text.py # clean_for_speech (+ pymorphy3/num2words для времени)
│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла) │ ├── stt.py # transcribe (Groq, BytesIO, без temp файла)
│ ├── audio.py # record, record_with_timeout (VAD) │ ├── audio.py # record (RMS VAD)
│ ├── tts.py # ElevenLabs streaming через mpv, barge-in │ ├── tts.py # ElevenLabs streaming через mpv stdin
│ ├── llm.py # ask_agent_stream, Conversation (history) │ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
│ └── modes.py # run_with_enter, run_with_porcupine │ └── modes.py # run_with_enter / run_with_porcupine + /new через slash
├── record_wav.py # запись обучающих wav-ов для wake word
├── remove_silent.py # чистка тихих записей
├── training/ # пайплайн обучения wake word (не в git)
└── deploy/ └── deploy/
├── setup.sh # установка на Raspberry Pi ├── setup.sh # установка на Raspberry Pi
└── cosmo-satellite.service # systemd unit └── cosmo-satellite.service # systemd unit
``` ```
## Что важно знать ## Ключевые инварианты
### Сессии диалога ### Сессии диалога
- **Одна сессия на день** для каждого агента. Это осознанное решение: каждая новая сессия в OpenClaw тяжёлая (чтение памяти, большой контекст). - История и контекст **на стороне OpenClaw**. Клиент шлёт только текущий user-message + `x-openclaw-session-key`.
- История хранится в `Conversation.messages[]` на клиенте и отправляется целиком с каждым запросом (stateless к серверу). - Сброс: фраза «начни новую сессию» / «сбрось историю» / «очисти контекст» (паттерны в `llm.py::RESET_PATTERNS`) → `modes._handle_reset` делает прямой POST с `/new`.
- Сброс сессии: фраза "начни новую сессию" / "сбрось историю" / "очисти контекст" — паттерны в `RESET_PATTERNS` в `llm.py`. - Автосброс по таймауту **пока не реализован** (кандидат Этапа 1).
- Автосброс при смене даты (`Conversation.is_expired()`).
- `MAX_HISTORY=20` — лимит сообщений, чтобы не раздувать контекст.
### Оптимизации скорости (все уже внедрены) ### Оптимизации скорости
1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py._make_session()`, переиспользуется TCP/TLS. 1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py::_make_session()`, переиспользует TCP/TLS.
2. **Streaming TTS** — ElevenLabs аудио пайпится в `mpv` через stdin, играет пока генерируется. 2. **Streaming TTS** — ElevenLabs аудио пайпится в `mpv` через stdin, играет пока генерируется.
3. **STT без диска** — PCM → WAV в `BytesIO` → Groq, без temp файлов. 3. **STT без диска** — PCM → WAV в `BytesIO` → Groq.
4. **Barge-in**`stop_speaking()` вызывается при каждой активации, убивает текущий mpv процесс. 4. **Низкокачественный mp3 для речи** (`mp3_22050_32`) — меньше латентность без заметной потери качества для голоса.
5. **`optimize_streaming_latency=3`** в ElevenLabs convert — выдаёт первый чанк быстрее.
### Роутинг по wake word ### Роутинг по wake word
В `modes.py::run_with_porcupine` Porcupine грузит оба wake word: `modes.py::run_with_porcupine` сейчас грузит только модель Cosmo. Код Люси закомментирован до того, как модель обучена. Когда готова:
- index 0 = Cosmo `AGENTS["cosmo"]` (:18789) - index 0 → `AGENTS["cosmo"]` (:18789)
- index 1 = Люся`AGENTS["lusya"]` (:18790) - index 1 → `AGENTS["lusya"]` (:18790)
Каждый агент имеет свой `tts_voice` в ElevenLabs. ### Нормализация речи перед TTS
Два слоя защиты:
1. `text.py::clean_for_speech` — regex-правила: эмодзи, `**жирный**`, числа с плюсом/минусом, проценты, слэши, градусы, аббревиатуры (`т.е.`, `т.к.`), UNIT_SLASH (км/ч → «километров в час»), время `HH:MM` → прописью в правильном падеже через pymorphy3 + num2words.
2. `llm.py::strip_fillers` — режет фразы-заглушки («сейчас посмотрю», «дай секунду») которые агент генерит перед вызовом tool.
### Ошибки не должны ронять сервис ### Ошибки не должны ронять сервис
Каждый слой (stt, tts, llm, audio, modes) ловит `Exception` и пишет в `errors.log` через `config.log`. Верхний уровень в modes.py ловит всё непредвиденное и продолжает цикл. Каждый слой (stt, tts, llm, audio, modes) ловит `Exception` и пишет в `errors.log` через `config.log`. Верхний уровень в modes.py ловит всё непредвиденное и продолжает цикл.
@@ -88,103 +86,126 @@ home-voice-assistant/
### macOS / Windows (разработка) ### macOS / Windows (разработка)
```bash ```bash
python -m venv .venv python -m venv .venv && source .venv/bin/activate
# macOS/Linux: source .venv/bin/activate
# Windows: .venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env # заполнить ключи cp .env.example .env # заполнить ключи
python satellite.py # режим Enter (без wake word) python satellite.py # режим Enter (без wake word)
python satellite.py --wake # режим Porcupine (нужны .ppn + PORCUPINE_KEY) python satellite.py --wake # режим openwakeword (нужна обученная .onnx)
``` ```
### Raspberry Pi (продакшн) ### Raspberry Pi (продакшн)
```bash ```bash
sudo bash deploy/setup.sh sudo bash deploy/setup.sh
# далее:
sudo systemctl start cosmo-satellite sudo systemctl start cosmo-satellite
sudo journalctl -u cosmo-satellite -f sudo journalctl -u cosmo-satellite -f
``` ```
## Зависимости системы ## Зависимости системы
- **Python 3.12+** - Python 3.12+
- **portaudio** — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`) - `portaudio` — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`)
- **mpv** — для воспроизведения TTS (`brew install mpv` / `apt install mpv`) - `mpv` — для воспроизведения TTS (`brew install mpv` / `apt install mpv`)
- **ffmpeg** — опционально, для совместимости форматов
### Windows ## Переменные окружения (ключевые)
- Python 3.12+ с pip
- `pip install pyaudio` — обычно работает через колеса pipwin или pre-built wheels. Если нет — `pip install pipwin && pipwin install pyaudio`
- mpv: скачать с [mpv.io](https://mpv.io/installation/), положить `mpv.exe` в PATH
- Porcupine работает и на Windows — wake word модель нужна под платформу windows (качать отдельную `.ppn`)
## Переменные окружения
Все в `.env`. Ключевые:
| Переменная | Что | | Переменная | Что |
|-----------|-----| |---|---|
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway на N100 | | `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway |
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Токены авторизации | | `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены |
| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw (`openclaw/main`, `openclaw/wife`) | | `AGENT`, `LUSYA_AGENT` | Имя агента (`openclaw/main`, `openclaw/wife`) |
| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | Модель LLM для голоса | | `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) |
| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии OpenClaw |
| `GROQ_API_KEY` | Groq для STT | | `GROQ_API_KEY` | Groq для STT |
| `ELEVENLABS_API_KEY` | ElevenLabs TTS | | `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL` | TTS |
| `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID в ElevenLabs | | `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID в ElevenLabs |
| `ELEVENLABS_MODEL` | `eleven_flash_v2_5` (быстрый) | | `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям wake word |
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто. | | `WAKE_THRESHOLD` | Порог активации (0..1, дефолт 0.5) |
| `PORCUPINE_KEY`, `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Только для `--wake` режима | | `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто |
| `SILENCE_THRESHOLD=500` | VAD: чувствительность (ниже = ловит тихую речь) | | `SILENCE_THRESHOLD`, `SILENCE_DURATION`, `MAX_DURATION`, `FOLLOWUP_TIMEOUT` | VAD |
| `SILENCE_DURATION=1.5` | Сек тишины = конец фразы | | `TTS_MODE` | `full` (целостная интонация) или `stream` (быстрый старт, рваный) |
| `FOLLOWUP_TIMEOUT=8` | Сек ожидания продолжения диалога | | `ECHO_WARMUP` | Сек пропуска в начале записи (гасит эхо от TTS) |
| `MAX_HISTORY=20` | Макс. сообщений в сессии |
## Частые задачи ## Частые задачи
**Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID берётся на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library). **Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library). Для русского лучше native-русские голоса, а не мультиязычные (не будет англоязычного акцента).
**Отладить VAD (ассистент не слышит / слушает слишком долго)**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек). **Отладить VAD**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек).
**Добавить третьего агента**: в `config.py::AGENTS` новый ключ, в `modes.py::run_with_porcupine` добавить `WAKE_WORD_*` и `wake_word_map.append(...)`. **Добавить третьего агента**: в `config.py::AGENTS` новый ключ + `WAKE_WORD_*` + раскомментировать блок в `modes.py::run_with_porcupine`.
**Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-openclaw-model`. Модель `openclaw/main` остаётся как agent (это маршрут в OpenClaw). **Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-ocplatform-model`. Поле `model` в JSON остаётся `openclaw/main` (это имя агента, а не LLM).
**Добавить фразу-заглушку**: в `llm.py::FILLER_PATTERNS` дополнить список. Эти фразы режутся из ответа перед TTS — агент генерит их до tool-call.
## Что НЕ делать ## Что НЕ делать
- Не комитить `.env` (есть в `.gitignore`) - Не комитить `.env` (есть в `.gitignore`)
- Не возвращать fallback на macOS `say` — проект специально унифицирован на ElevenLabs + mpv - Не возвращать `say`/`espeak` — проект унифицирован на ElevenLabs + mpv
- Не создавать новую сессию Conversation на каждую активацию — это было в старой версии, сейчас одна сессия на день - Не хранить историю диалога на клиенте — это делает OpenClaw по `session_key`
- Не добавлять temp файлы для WAV/mp3 — всё идёт через `BytesIO` / stdin pipe - Не создавать temp файлы для WAV/mp3 — всё через `BytesIO` / stdin pipe
- Не включать `style>0` и `speed≠1.0` в VoiceSettings — усиливают «иностранный» акцент и ломают просодию
## Тренировка своего wake word ## Тренировка wake word
Пайплайн в `training/`: Пайплайн в `training/` (игнорируется в git):
- `record_wav.py <model> <positive|negative>` — запись 16kHz mono PCM 16-bit в `training/own_samples/<model>/` - `record_wav.py <model> <positive|negative> [long <sec>]` — запись 16kHz mono PCM 16-bit
- `training/step_1.py``step_5.py` — установка зависимостей, конвертация датасетов, генерация конфига, обучение, экспорт в `data/models/<name>.onnx` - `remove_silent.py` — чистка + перенумерация
- `training/training_config.json` — параметры (`wake_word_list`, `use_own_samples`, штрафы, шаги) - `step_1.py … step_5.py` — зависимости, датасеты, конфиг, обучение, экспорт
- `training/openwakeword/` — форк openwakeword, `examples/custom_model.yml` — базовый шаблон конфига - `training_config.json` — параметры (`wake_word_list`, `use_own_samples`, пенальти, шаги)
- Под капотом: openwakeword (НЕ Porcupine, несмотря на легаси-имена в коде). Wake word работает через DNN-модель .onnx. - Под капотом: openwakeword (DNN .onnx)
Реалистичные цифры для своего голоса: 500+ positive и 1000+ negative wav-файлов, иначе recall/FP/hour не сходятся. Negative должны включать фонетически близкие слова. Реалистично для своего голоса: 500+ positive и 1000+ negative, иначе recall < 0.4. Negative должны включать фонетически близкие слова («космос», «просто»).
## Roadmap ## Состояние и планы
### Done ### Done
- [x] Модулизация satellite.py (audio/stt/llm/tts/modes/config) - [x] Модульная структура (`audio/stt/llm/tts/modes/config/text`)
- [x] ElevenLabs streaming TTS + mpv pipe - [x] ElevenLabs streaming + mpv pipe
- [x] Keep-alive HTTP сессии, STT через BytesIO, barge-in - [x] Keep-alive HTTP сессии, STT через BytesIO
- [x] Сессии диалога (одна на день, MAX_HISTORY, паттерны сброса) - [x] Серверные сессии OpenClaw через `x-openclaw-session-key` (не клиентская история)
- [x] Пайплайн тренировки своего wake word на собственных записях - [x] Slash-команда `/new` на фразу «начни новую сессию»
- [x] Нормализация речи: числа, единицы, время через pymorphy3 + num2words
- [x] Пайплайн тренировки своего wake word + скрипты записи/чистки датасета
- [x] systemd unit и setup.sh для Pi 5
### In progress ### 🚧 In progress / нужно сделать
- [ ] Дообучение модели cosmo (на текущем датасете 300 pos / 117 neg метрики плохие — recall 25%, FP/hr 32). Нужно дозаписать данные. - [ ] **Чистка**: удалить `start_barge_in_listener`/`was_barge_in` из `tts.py`, параметр `conv` из `llm.py`, импорты `sys`/`start_barge_in_listener`/`was_barge_in` из `modes.py`
- [ ] Подключить Люсю в `run_with_wakeword` (сейчас грузится только модель cosmo, lusya wake word не работает) - [ ] **`FOLLOWUP_TIMEOUT` реально применяется** — сейчас задекларирован, но после ответа ассистент ждёт полный `MAX_DURATION=15s` если пользователь молчит
- [ ] **Унифицировать дефолт session_key** в `config.py` и `.env.example` (сейчас `voice:home:cosmo` vs `agent:voice:voice:home`)
- [ ] **`max_tokens` → env** (`VOICE_MAX_TOKENS`), дефолт 300
- [ ] **Дообучить модель cosmo до recall ≥ 0.7** (нужно 500+ positive + разнообразие)
- [ ] **Подключить Люсю в `run_with_porcupine`** (код закомментирован, готов к включению)
- [ ] **Проверить systemd autostart на Pi в проде** — unit есть, в прод не поставлен
- [ ] **logrotate / size-cap на `errors.log`** — растёт неограниченно
### Planned ### 📋 Roadmap Этап 2 — качество и надёжность
- [ ] systemd autostart на Raspberry Pi (`deploy/cosmo-satellite.service` есть, но не проверен в проде) - [ ] **Автосброс OpenClaw сессии по таймауту** (>1 ч тишины → `/new`)
- [ ] Home Assistant tool в OpenClaw воркспейсе (управление светом/температурой через голос) - [ ] **Retry с backoff** для gateway (3 попытки с экспонентой)
- [ ] Real-time barge-in (прерывание по голосу во время озвучки, не только по новой активации) - [ ] **TTS-cache** для дежурных реплик («Начинаю новую сессию», «Не слышу», «Ошибка сервера»)
- [ ] Контекст окружения в system prompt (время, погода, состояние устройств) - [ ] **Persistent PyAudio input stream** (не пересоздавать на каждый `record()`)
- [ ] Speaker identification (определять кто говорит без разных wake words) - [ ] **Заменить RMS-VAD на `webrtcvad` или `silero-vad`** — RMS не работает с фоновой музыкой
- [ ] Проактивные уведомления (WebSocket от сервера → satellite сам начинает говорить) - [ ] **Whisper `prompt` параметр** с «Космо, Люся, OpenClaw» — для имён собственных
- [ ] **SYSTEM_PROMPT опционально на клиенте** — подсказка про TTS-friendly формат чисел/дат, если OpenClaw-агент без неё
### 📋 Roadmap Этап 3 — новые фичи
- [ ] **Home Assistant tool** в OpenClaw: свет, климат, медиа голосом
- [ ] **Контекст окружения** в каждом запросе: время, комната, погода, кто говорит
- [ ] **Proactive notifications**: OpenClaw → WebSocket/SSE → satellite сам инициирует речь (таймеры, напоминания, входящее сообщение)
- [ ] **Realtime barge-in голосом** во время TTS (требует echo cancellation: speex AEC или SpeexDSP)
- [ ] **No-wake mode для доверенной комнаты** — VAD + whisper + intent filter без обязательного wake word
- [ ] **Streaming TTS пер-токен** — отправлять в TTS куски раньше чем целое предложение, с правильными интонационными точками
### 📋 Roadmap Этап 4 — амбициозное
- [ ] **Speaker identification** (`pyannote.audio` / `resemblyzer`) — разные персонализации по голосу
- [ ] **Multi-room координация** — MQTT/gRPC между сателлитами, отвечает тот, кто слышит громче
- [ ] **Локальный fallback LLM** на Pi (phi/llama) когда gateway недоступен — базовые команды без облака
- [ ] **Камера + vision** — агент видит кто в комнате, что происходит
- [ ] **Voice-memory hooks UX** — голосовые команды «запомни», «забудь» (OpenClaw уже умеет, нужен голосовой слой)
## Известные ограничения
- **Нет echo cancellation** — если колонки близко к микрофону (особенно BT колонки на Pi), TTS может триггерить wake-модель. Mitigation: разносить колонку и мик, поднимать `WAKE_THRESHOLD`, использовать наушники при отладке.
- **VAD не отличает голос от музыки/ТВ** — если что-то постоянно шумит выше `SILENCE_THRESHOLD`, ассистент «зависнет» слушать. Решение — `silero-vad`.
- **Одна сессия = бесконечный контекст OpenClaw** пока не вызван `/new`. Нужен автотаймаут.
- **Нет fallback для gateway-offline** — при потере связи ассистент молчит до явной ошибки.

230
README.md
View File

@@ -1,19 +1,24 @@
# Cosmo Voice Satellite # Cosmo Voice Satellite
Домашний голосовой ассистент. Слушает wake word, распознаёт речь, ходит в OpenClaw gateway, проигрывает ответ через ElevenLabs. Домашний голосовой ассистент поверх LLM — аналог Алисы/Siri, но умнее, потому что за ним стоит полноценный агент OpenClaw с памятью, tools и инструментами.
Два агента: **Cosmo** (владельца) и **Люся** (жены) — каждый со своим wake word и своим gateway. Два агента **Cosmo** (владельца) и **Люся** (жены). Каждый со своим wake word, своим голосом ElevenLabs и своим OpenClaw gateway.
## Архитектура ## Архитектура
``` ```
mic ─► wake word (openwakeword) ─► STT (Groq) ─► OpenClaw gateway ─► TTS (ElevenLabs) ─► mpv ─► speakers mic ─► wake word (openwakeword)
└► STT (Groq whisper) ─► OpenClaw Gateway (session_key, N100) ─► LLM
▼ streamed text
ElevenLabs TTS ─► mpv stdin ─► speakers
``` ```
- **Wake word:** openwakeword (обучается на своих записях, см. ниже). Раньше планировался Porcupine — отказались. - **Wake word**: openwakeword (`.onnx`, обученная на своих записях)
- **STT:** Groq API, `whisper-large-v3-turbo`, ru. - **STT**: Groq API, `whisper-large-v3-turbo`, ru
- **LLM:** OpenClaw gateway на N100 (`192.168.31.103:18789` для cosmo, `:18790` для lusya), `openai/gpt-5.4-mini`. - **Агент**: OpenClaw на N100, модели через `x-ocplatform-model` header (`openai/gpt-5.4-mini` и т.п.)
- **TTS:** ElevenLabs `eleven_flash_v2_5` стримом через mpv stdin. - **История диалога**: на сервере OpenClaw (per `session_key`), клиент stateless
- **TTS**: ElevenLabs streaming через mpv
## Структура ## Структура
@@ -22,53 +27,61 @@ home-voice-assistant/
├── satellite.py # entry-обёртка ├── satellite.py # entry-обёртка
├── satellite/ # рантайм ├── satellite/ # рантайм
│ ├── __main__.py # python -m satellite [--wake] │ ├── __main__.py # python -m satellite [--wake]
│ ├── config.py, text.py │ ├── config.py # AGENTS, keep-alive sessions
│ ├── stt.py, audio.py, tts.py, llm.py │ ├── audio.py # запись + RMS VAD
── modes.py # run_with_enter / run_with_porcupine (wake word) ── stt.py # Groq whisper
├── record_wav.py # запись датасета для wake word │ ├── llm.py # ask_agent_stream, strip_fillers, RESET_PATTERNS
│ ├── tts.py # ElevenLabs → mpv stdin
│ ├── text.py # clean_for_speech (+ pymorphy3 для времени)
│ └── modes.py # run_with_enter / run_with_porcupine
├── record_wav.py # запись датасета wake word
├── remove_silent.py # чистка тихих + перенумерация ├── remove_silent.py # чистка тихих + перенумерация
├── training/ # пайплайн обучения wake word ├── training/ # openwakeword пайплайн (в .gitignore)
│ ├── step_1.py … step_5.py
│ ├── training_config.json
│ ├── own_samples/<word>/{positive,negative}/*.wav
│ ├── openwakeword/ # форк
│ └── my_custom_model/<word>/ # фичи + .onnx
├── data/models/ # готовые .onnx wake word моделей ├── data/models/ # готовые .onnx wake word моделей
└── deploy/ # setup.sh + systemd unit для Pi └── deploy/ # setup.sh + systemd unit для Pi 5
``` ```
## Запуск ## Быстрый старт
```bash ```bash
python -m venv .venv && source .venv/bin/activate python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env # заполнить ключи cp .env.example .env # заполнить ключи
python satellite.py # режим Enter (без wake word, для отладки) python satellite.py # режим Enter (отладка)
python satellite.py --wake # режим wake word (нужна обученная модель в data/models/) python satellite.py --wake # режим wake word (нужна модель в data/models/)
``` ```
Системные зависимости: ### Системные зависимости
- Python 3.12+
- `portaudio` `brew install portaudio` macOS: `brew install portaudio mpv`
- `mpv``brew install mpv` Linux: `apt install portaudio19-dev mpv`
Windows: мpv с [mpv.io](https://mpv.io), `pip install pipwin && pipwin install pyaudio`
## Сессия диалога
История и память живут **на стороне OpenClaw**. Satellite отправляет только текущее сообщение + `x-openclaw-session-key`. Сервер сам подклеивает контекст.
Сброс сессии:
- Голосом: «начни новую сессию» / «сбрось историю» / «очисти контекст» → satellite шлёт slash-команду `/new` в OpenClaw
- Программно: меняй `COSMO_SESSION_KEY` в `.env`
## Обучение своего wake word ## Обучение своего wake word
OpenWakeWord обучает DNN-модель на твоих записях слова. Пайплайн в `training/`: OpenWakeWord тренирует DNN-модель на твоих записях слова. Пайплайн в `training/`:
| Шаг | Что делает | | Шаг | Что делает |
|-----|-----------| |-----|-----------|
| `step_1.py` | Установка зависимостей (piper, openwakeword) | | `step_1.py` | Установка зависимостей (piper, openwakeword) |
| `step_2.py` | Создаёт `training_config.json` (параметры обучения) | | `step_2.py` | Создаёт `training_config.json` |
| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features) — ~17 GB | | `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features, ~17 GB) |
| `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` | | `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` |
| `step_5.py` | Проверка моделей и подсказки для `.env` | | `step_5.py` | Проверка моделей и подсказки для `.env` |
### Запись датасета ### Запись датасета
```bash ```bash
# по одной записи (Enter → 2 секунды → сохраняем) # по одной записи (Enter → 2 с → сохраняем)
python record_wav.py cosmo positive python record_wav.py cosmo positive
python record_wav.py cosmo negative python record_wav.py cosmo negative
@@ -77,7 +90,7 @@ python record_wav.py cosmo negative long 300
INPUT_DEVICE=1 python record_wav.py cosmo negative long 600 INPUT_DEVICE=1 python record_wav.py cosmo negative long 600
``` ```
`record_wav.py` отбраковывает тихие записи по `MIN_RMS=300` (изменить можно константой в начале файла). `record_wav.py` отбраковывает тихие записи по `MIN_RMS=300`.
### Чистка ### Чистка
@@ -89,85 +102,126 @@ python remove_silent.py
### Тренировка ### Тренировка
1. В `training/training_config.json` укажи `wake_word_list`, `use_own_samples: true`, параметры: ```bash
```json # в training/training_config.json:
{ # {
"wake_word_list": ["cosmo"], # "wake_word_list": ["cosmo"],
"use_own_samples": true, # "use_own_samples": true,
"false_activation_penalty": 100, # "false_activation_penalty": 100,
"target_false_positives_per_hour": 3.0, # "target_false_positives_per_hour": 3.0,
"target_recall": 0.5, # "target_recall": 0.5,
"number_of_training_steps": 3000, # "number_of_training_steps": 3000,
"layer_size": 64 # "layer_size": 64
} # }
```
2. В `training/openwakeword/examples/custom_model.yml` подними `augmentation_rounds: 10` (или больше). rm -rf training/my_custom_model/cosmo data/models/cosmo.onnx
3. Снеси кэш если был старый запуск: python training/step_4.py
```bash # → .env: WAKE_WORD_COSMO=data/models/cosmo.onnx
rm -rf training/my_custom_model/<word> data/models/<word>.onnx ```
```
4. Запусти:
```bash
python training/step_4.py
```
5. Пропиши в `.env`: `WAKE_WORD_COSMO=data/models/cosmo.onnx`.
### Сколько данных нужно ### Сколько данных нужно
| Positive | Negative | Recall | | Positive | Negative | Ожидаемый recall |
|---|---|---| |---|---|---|
| 100200 | 200+ | 0.10.3 (плохо) | | 100200 | 200+ | 0.10.3 (плохо) |
| 300500 | 500+ | 0.40.6 (минимум) | | 300500 | 500+ | 0.40.6 (минимум для работы) |
| 8001500 | 1000+ | 0.70.85 | | 8001500 | 1000+ | 0.70.85 |
| 2000+ | 2000+ | 0.9+ | | 2000+ | 2000+ | 0.9+ |
Главное — **разнообразие**: разные дистанции до микрофона, интонации, время дня, фоны. Аугментация (`augmentation_rounds`) умножит твой датасет в N раз во время обучения. Главное — **разнообразие**: разные дистанции, интонации, время дня, фоны. Негативы должны включать фонетически близкие слова («космос», «косо», «просто»), обычную речь, имена других ассистентов («Алиса», «Сири»).
Негативы должны включать **фонетически близкие** слова ("космос", "косо", "просто"), обычную речь, имена других ассистентов ("алиса", "сири"), бытовые звуки.
## Архитектурные решения
- **Одна сессия диалога на день** на агента (`Conversation` в `llm.py`). История хранится клиентом, отправляется целиком. Сброс — фразой "сбрось историю" или сменой даты.
- **Keep-alive HTTP** (`requests.Session`) — переиспользует TCP/TLS.
- **Streaming TTS** — ElevenLabs пайпится в `mpv` через stdin, играет пока генерируется.
- **STT без диска** — PCM → WAV в `BytesIO` → Groq.
- **Barge-in** — `stop_speaking()` убивает mpv при новой активации.
- **Ошибки не роняют сервис** — каждый слой ловит `Exception`, пишет в `errors.log`.
## .env (ключевые переменные) ## .env (ключевые переменные)
| Переменная | Что | | Переменная | Что |
|---|---| |---|---|
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | OpenClaw gateways | | `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway |
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Авторизация | | `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Bearer токены |
| `AGENT`, `LUSYA_AGENT` | `openclaw/main`, `openclaw/wife` | | `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw |
| `VOICE_MODEL` | LLM для голоса (передаётся в `x-openclaw-model`) | | `VOICE_MODEL` | LLM (передаётся в `x-ocplatform-model`) |
| `COSMO_SESSION_KEY`, `LUSYA_SESSION_KEY` | Идентификатор серверной сессии |
| `GROQ_API_KEY` | STT | | `GROQ_API_KEY` | STT |
| `ELEVENLABS_API_KEY`, `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | TTS | | `ELEVENLABS_API_KEY`, `ELEVENLABS_MODEL` | TTS |
| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям | | `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID |
| `SILENCE_THRESHOLD`, `SILENCE_DURATION` | VAD | | `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` |
| `MAX_HISTORY` | Лимит сообщений в сессии | | `WAKE_THRESHOLD` | Порог активации (дефолт 0.5) |
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink` | | `TTS_MODE` | `full` (цельная интонация) / `stream` (быстрый старт) |
| `AUDIO_SINK` | На Pi: BT sink. На Mac/Win: пусто |
| `SILENCE_THRESHOLD`, `SILENCE_DURATION`, `MAX_DURATION`, `FOLLOWUP_TIMEOUT` | VAD |
## Деплой на Raspberry Pi ## Деплой на Raspberry Pi 5
```bash ```bash
sudo bash deploy/setup.sh sudo bash deploy/setup.sh
# подключи BT колонку, пропиши AUDIO_SINK в .env
# положи обученную .onnx в data/models/cosmo.onnx
sudo systemctl start cosmo-satellite sudo systemctl start cosmo-satellite
sudo journalctl -u cosmo-satellite -f sudo journalctl -u cosmo-satellite -f
``` ```
## Roadmap ## Roadmap
- [x] Модулизация satellite ### ✅ Сделано
- [x] ElevenLabs streaming + barge-in
- [x] Сессии диалога с автосбросом - Модульная структура satellite
- [x] Пайплайн тренировки wake word на своих записях - ElevenLabs streaming TTS через mpv pipe
- [ ] Обучить рабочую модель cosmo (нужно ~500+ позитивов) - Keep-alive HTTP + STT без диска
- [ ] Подключить Люсю в `run_with_porcupine` (сейчас грузится только cosmo) - Серверные сессии OpenClaw (`x-openclaw-session-key`)
- [ ] Проверить systemd autostart на Pi в проде - Slash-команда `/new` для сброса голосом
- [ ] Home Assistant tool в OpenClaw - Нормализация речи (числа, время, единицы) через pymorphy3 + num2words
- [ ] Real-time barge-in (прерывание голосом во время TTS) - Пайплайн тренировки wake word на своих записях
- [ ] Контекст окружения в system prompt - systemd unit для Pi
- [ ] Speaker identification
- [ ] Проактивные уведомления через WebSocket ### 🚧 В работе
- Дообучить wake-модель до recall ≥ 0.7 (нужно 500+ позитивов + разнообразие)
- Подключить Люсю в `run_with_porcupine` (код готов, закомментирован)
- Чистка мёртвого кода (`start_barge_in_listener`, `conv` и т.п.)
- Починить `FOLLOWUP_TIMEOUT` (сейчас после ответа ассистент ждёт полный 15 с)
### 📋 Этап 2 — качество и надёжность
- Автосброс OpenClaw сессии по таймауту (>1 ч → `/new`)
- Retry с backoff для gateway
- TTS-cache дежурных реплик
- Persistent PyAudio stream (быстрее запись на Pi)
- Заменить RMS-VAD на `webrtcvad` / `silero-vad` — RMS ломается с фоновой музыкой
- Whisper `prompt` с именами собственными
- Size-cap / logrotate для `errors.log`
### 📋 Этап 3 — «умнее Алисы»
- **Home Assistant tool** в OpenClaw: свет/климат/медиа голосом
- **Контекст окружения** в каждом запросе: время, комната, погода, кто говорит
- **Proactive notifications**: OpenClaw → WebSocket/SSE → satellite сам начинает говорить (таймеры, напоминания, входящие)
- **Realtime barge-in голосом** — прерывать TTS, когда пользователь начал говорить (требует echo cancellation)
- **No-wake mode** в доверенной комнате — VAD + STT + intent filter без обязательного wake word
- **Streaming TTS пер-токен** — выдавать в речь куски раньше полного предложения
### 📋 Этап 4 — амбициозное
- **Speaker identification** (`pyannote.audio` / `resemblyzer`) — разные персонализации по голосу
- **Multi-room координация** — MQTT между сателлитами, отвечает тот, кто слышит громче
- **Локальный fallback LLM** на Pi когда gateway оффлайн (phi/llama для простых команд)
- **Камера + vision** — агент видит кто в комнате, что происходит
- **Voice-memory hooks UX** — голосовое «запомни/забудь»
## Известные ограничения
- Нет echo cancellation — если колонки близко к мику, TTS может триггерить wake-модель (поднимай `WAKE_THRESHOLD`).
- RMS-VAD не отличает голос от музыки/ТВ — ассистент может «залипнуть» в шумной среде.
- OpenClaw-сессия живёт вечно, пока не сказать «сбрось» — контекст раздувается.
- При потере связи с gateway — ассистент молчит до явной ошибки.
## Troubleshooting
**Ассистент «иностранец», монотонно читает** → смени `ELEVENLABS_MODEL` на `eleven_multilingual_v2`, возьми native-русский голос из Voice Library (не default English-speakers), в `tts.py` поставь `style=0.0, speed=1.0` (style/speed ломают просодию).
**Числа читаются цифрами вместо слов** → проверь `text.py::clean_for_speech` применяется. Для времени нужны `num2words` и `pymorphy3` в requirements.
**Wake срабатывает сам по себе, когда TTS говорит**`WAKE_THRESHOLD=0.7`, разнеси колонку и мик. В будущем — AEC.
**Ассистент не реагирует на тихий голос** → понижай `SILENCE_THRESHOLD` (дефолт 500).
**`pyaudio` не ставится на Windows** → `pip install pipwin && pipwin install pyaudio`, или бери pre-built wheel.
**Wake модель плохо работает** → дело в данных. Смотри таблицу выше — нужно минимум 500 позитивов с разнообразием.

View File

@@ -6,12 +6,21 @@ numpy<2
pyaudio pyaudio
sounddevice sounddevice
scipy<1.15 scipy<1.15
webrtcvad-wheels
# STT через облако # STT через облако
groq groq
# LLM — прямой Claude (альтернатива OpenClaw, активируется LLM_BACKEND=claude)
anthropic>=0.50.0
# TTS # TTS
elevenlabs elevenlabs
# Wake word # Wake word
openwakeword openwakeword
# Русская морфология для нормализации текста под TTS
num2words
pymorphy3
pymorphy3-dicts-ru

View File

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

View File

@@ -19,16 +19,19 @@ logging.basicConfig(
) )
log = logging.getLogger("cosmo") log = logging.getLogger("cosmo")
# OpenClaw Gateway — Cosmo (по умолчанию) # Какой LLM backend — openclaw (дефолт) или claude (прямой Anthropic).
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://192.168.31.103:18789") # В конфиге используется для решения «требовать ли OpenClaw credentials».
GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN") LLM_BACKEND_CFG = os.getenv("LLM_BACKEND", "openclaw").lower()
AGENT = os.getenv("AGENT", "openclaw/main")
VOICE_MODEL = os.getenv("VOICE_MODEL", "openai/gpt-4o-mini") # 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 — Люся # 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_GATEWAY_TOKEN = os.getenv("LUSYA_GATEWAY_TOKEN", GATEWAY_TOKEN)
LUSYA_AGENT = os.getenv("LUSYA_AGENT", "openclaw/wife")
LUSYA_VOICE_MODEL = os.getenv("LUSYA_VOICE_MODEL", VOICE_MODEL) LUSYA_VOICE_MODEL = os.getenv("LUSYA_VOICE_MODEL", VOICE_MODEL)
# Keep-alive HTTP сессии — переиспользуют TCP/TLS соединения # Keep-alive HTTP сессии — переиспользуют TCP/TLS соединения
@@ -46,20 +49,16 @@ AGENTS = {
"cosmo": { "cosmo": {
"name": "Cosmo", "name": "Cosmo",
"gateway_url": GATEWAY_URL, "gateway_url": GATEWAY_URL,
"token": GATEWAY_TOKEN,
"agent": AGENT,
"voice_model": VOICE_MODEL, "voice_model": VOICE_MODEL,
"session_key": os.getenv("COSMO_SESSION_KEY", "voice:home:cosmo"), "session_key": os.getenv("COSMO_SESSION_KEY", "agent:main:voice:home"),
"tts_voice": os.getenv("COSMO_TTS_VOICE", ""), "tts_voice": os.getenv("COSMO_TTS_VOICE", ""),
"session": _make_session(GATEWAY_TOKEN), "session": _make_session(GATEWAY_TOKEN),
}, },
"lusya": { "lusya": {
"name": "Люся", "name": "Люся",
"gateway_url": LUSYA_GATEWAY_URL, "gateway_url": LUSYA_GATEWAY_URL,
"token": LUSYA_GATEWAY_TOKEN,
"agent": LUSYA_AGENT,
"voice_model": LUSYA_VOICE_MODEL, "voice_model": LUSYA_VOICE_MODEL,
"session_key": os.getenv("LUSYA_SESSION_KEY", "voice:home:lusya"), "session_key": os.getenv("LUSYA_SESSION_KEY", "agent:wife:voice:home"),
"tts_voice": os.getenv("LUSYA_TTS_VOICE", ""), "tts_voice": os.getenv("LUSYA_TTS_VOICE", ""),
"session": _make_session(LUSYA_GATEWAY_TOKEN), "session": _make_session(LUSYA_GATEWAY_TOKEN),
}, },
@@ -73,10 +72,22 @@ SILENCE_THRESHOLD = int(os.getenv("SILENCE_THRESHOLD", "500"))
SILENCE_DURATION = float(os.getenv("SILENCE_DURATION", "1.5")) SILENCE_DURATION = float(os.getenv("SILENCE_DURATION", "1.5"))
MAX_DURATION = int(os.getenv("MAX_DURATION", "15")) MAX_DURATION = int(os.getenv("MAX_DURATION", "15"))
FOLLOWUP_TIMEOUT = float(os.getenv("FOLLOWUP_TIMEOUT", "8")) FOLLOWUP_TIMEOUT = float(os.getenv("FOLLOWUP_TIMEOUT", "8"))
VAD_AGGRESSIVENESS = int(os.getenv("VAD_AGGRESSIVENESS", "2")) # webrtcvad 0..3
# LLM
VOICE_MAX_TOKENS = int(os.getenv("VOICE_MAX_TOKENS", "300"))
LLM_RETRIES = int(os.getenv("LLM_RETRIES", "3"))
# Barge-in (прерывание TTS голосом)
# Работает только при разнесённых колонке/мике или в наушниках — иначе эхо собственного TTS
# будет триггерить прерывание. По умолчанию выключен.
BARGE_IN_ENABLED = os.getenv("BARGE_IN_ENABLED", "false").lower() in ("1", "true", "yes")
BARGE_IN_THRESHOLD = int(os.getenv("BARGE_IN_THRESHOLD", "1500")) # RMS, обычно > SILENCE_THRESHOLD
BARGE_IN_WARMUP = float(os.getenv("BARGE_IN_WARMUP", "0.8")) # сек пропуска в начале TTS
# Groq client # Groq client
groq_client = Groq(api_key=os.getenv("GROQ_API_KEY")) groq_client = Groq(api_key=os.getenv("GROQ_API_KEY"))
if not GATEWAY_TOKEN: if LLM_BACKEND_CFG == "openclaw" and not GATEWAY_TOKEN:
print("❌ GATEWAY_TOKEN не задан в .env") print("❌ GATEWAY_TOKEN не задан в .env (нужен для LLM_BACKEND=openclaw)")
sys.exit(1) sys.exit(1)

View File

@@ -1,22 +1,19 @@
import json import json
import os import os
import re import re
import time
import requests import requests
from datetime import date
from .config import AGENTS, log from .config import AGENTS, VOICE_MAX_TOKENS, LLM_RETRIES, log
from .text import clean_for_speech, find_sentence_end from .text import clean_for_speech, find_sentence_end
from .tts import speak, play_error_sound from .tts import speak, play_error_sound
from . import notifier
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()
SYSTEM_PROMPT = (
"Отвечай кратко, 1-2 предложения, без markdown, без эмодзи. "
"Ответ будет озвучен голосом, поэтому: "
"числа пиши прописью (двадцать три, а не 23), "
"единицы измерения пиши полностью (километров в час, а не км/ч), "
"не используй спецсимволы (+, -, /, %, °) — заменяй словами (плюс, минус, из, процентов, градусов). "
"Температуру пиши так: 'плюс девять градусов', а не '+9°C'."
)
MAX_HISTORY = int(os.getenv("MAX_HISTORY", "20"))
# "stream" — режем по предложениям (быстро, но рваная интонация) # "stream" — режем по предложениям (быстро, но рваная интонация)
# "full" — собираем весь ответ, потом TTS (естественно, но пауза перед началом) # "full" — собираем весь ответ, потом TTS (естественно, но пауза перед началом)
TTS_MODE = os.getenv("TTS_MODE", "full") TTS_MODE = os.getenv("TTS_MODE", "full")
@@ -28,87 +25,102 @@ RESET_PATTERNS = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Фразы-заглушки которые агент генерирует ДО вызова инструмента
FILLER_PATTERNS = re.compile(
r'(?:(?:сейчас посмотрю|дай мне секунду|дай секунду|проверяю|загружаю|узнаю'
r'|смотрю|одну секунду|я сейчас посмотрю|я проверю|попробую другой источник'
r'|нужны конкретные числа|дай мне загрузить)[^.!?]*[.!?]?\s*)+',
re.IGNORECASE,
)
class Conversation:
"""Хранит историю сообщений — одна сессия на день"""
def __init__(self, agent_id: str = "cosmo"): def strip_fillers(text: str) -> str:
self.agent_id = agent_id return FILLER_PATTERNS.sub('', text).strip()
self.created_date = date.today()
self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]
def is_expired(self) -> bool:
return date.today() != self.created_date
def reset(self):
self.created_date = date.today()
self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]
def add_user(self, text: str):
self.messages.append({"role": "user", "content": text})
self._trim()
def add_assistant(self, text: str):
self.messages.append({"role": "assistant", "content": text})
self._trim()
def _trim(self):
if len(self.messages) > MAX_HISTORY + 1:
self.messages = [self.messages[0]] + self.messages[-(MAX_HISTORY):]
def is_reset_command(text: str) -> bool: def is_reset_command(text: str) -> bool:
return bool(RESET_PATTERNS.search(text)) return bool(RESET_PATTERNS.search(text))
def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: str = "cosmo") -> str: def _post_with_retry(session, url, headers, payload):
if conv is None: """POST с экспоненциальным backoff. Retry на сетевые ошибки и 5xx; 4xx — сразу вверх."""
conv = Conversation(agent_id) 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
conv.add_user(text)
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"]) cfg = AGENTS.get(agent_id, AGENTS["cosmo"])
gateway_url = cfg["gateway_url"] session_key = cfg.get("session_key", VOICE_SESSION_KEY)
session = cfg["session"]
agent = cfg["agent"] payload = {
"stream": True,
"messages": [{"role": "user", "content": text}],
"max_tokens": VOICE_MAX_TOKENS,
}
headers = {
"x-ocplatform-model": cfg["voice_model"],
"x-openclaw-session-key": session_key,
}
try: try:
resp = session.post( resp = _post_with_retry(
f"{gateway_url}/v1/chat/completions", cfg["session"], f"{cfg['gateway_url']}/v1/chat/completions", headers, payload,
headers={
"x-openclaw-model": cfg["voice_model"],
"x-openclaw-session-key": cfg["session_key"],
},
json={
"model": agent,
"stream": True,
"messages": conv.messages,
"max_tokens": 150,
},
stream=True,
timeout=60,
) )
resp.raise_for_status()
except requests.ConnectionError: except requests.ConnectionError:
log.exception("Gateway недоступен") log.exception("Gateway недоступен после retry")
msg = "Не могу связаться с сервером, попробуй ещё раз." msg = "Не могу связаться с сервером, попробуй ещё раз."
print(f"⚠️ {msg}") print(f"⚠️ {msg}")
play_error_sound() play_error_sound()
speak(msg, agent_id) notifier.error(msg, agent_id)
_maybe_speak(msg)
return msg return msg
except requests.Timeout: except requests.Timeout:
log.exception("Gateway таймаут") log.exception("Gateway таймаут после retry")
msg = "Сервер не ответил вовремя, попробуй ещё раз." msg = "Сервер не ответил вовремя, попробуй ещё раз."
print(f"⚠️ {msg}") print(f"⚠️ {msg}")
play_error_sound() play_error_sound()
speak(msg, agent_id) notifier.error(msg, agent_id)
_maybe_speak(msg)
return msg return msg
except requests.HTTPError: except requests.HTTPError as e:
log.exception(f"Gateway HTTP ошибка {resp.status_code}") status = e.response.status_code if e.response is not None else "?"
body = e.response.text if e.response is not None else ""
log.exception(f"Gateway HTTP {status}")
msg = "Ошибка сервера, попробуй ещё раз." msg = "Ошибка сервера, попробуй ещё раз."
print(f"⚠️ Gateway {resp.status_code}: {resp.text}") print(f"⚠️ Gateway {status}: {body[:200]}")
play_error_sound() play_error_sound()
speak(msg, agent_id) notifier.error(msg, agent_id)
_maybe_speak(msg)
return msg return msg
full_text = "" full_text = ""
@@ -118,7 +130,8 @@ def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: st
for line in resp.iter_lines(): for line in resp.iter_lines():
if not line or line == b"data: [DONE]": if not line or line == b"data: [DONE]":
continue continue
if line.startswith(b"data: "): if not line.startswith(b"data: "):
continue
try: try:
chunk = json.loads(line[6:]) chunk = json.loads(line[6:])
delta = chunk["choices"][0]["delta"].get("content", "") delta = chunk["choices"][0]["delta"].get("content", "")
@@ -131,12 +144,9 @@ def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: st
if TTS_MODE == "stream": if TTS_MODE == "stream":
last_punct = find_sentence_end(buffer, min_len=120) last_punct = find_sentence_end(buffer, min_len=120)
if last_punct > -1: if last_punct > -1:
sentence = clean_for_speech(buffer[:last_punct + 1]) sentence = clean_for_speech(strip_fillers(buffer[:last_punct + 1]))
if sentence.strip(): _maybe_speak(sentence)
print(f"🔊 Говорю: {sentence}")
speak(sentence, agent_id)
buffer = buffer[last_punct + 1:].lstrip() buffer = buffer[last_punct + 1:].lstrip()
except (json.JSONDecodeError, KeyError, IndexError): except (json.JSONDecodeError, KeyError, IndexError):
continue continue
except Exception as e: except Exception as e:
@@ -145,22 +155,15 @@ def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: st
if not full_text: if not full_text:
msg = "Не получил ответ, попробуй ещё раз." msg = "Не получил ответ, попробуй ещё раз."
speak(msg, agent_id) _maybe_speak(msg)
return msg return msg
result = clean_for_speech(full_text) result = clean_for_speech(strip_fillers(full_text))
if TTS_MODE == "full": if TTS_MODE == "full":
# LLM уже доримил — озвучиваем весь ответ одним куском с цельной интонацией _maybe_speak(result)
if result.strip():
print(f"🔊 Говорю: {result}")
speak(result, agent_id)
else: else:
# остаток буфера в stream-режиме
if buffer.strip(): if buffer.strip():
tail = clean_for_speech(buffer) _maybe_speak(clean_for_speech(strip_fillers(buffer)))
if tail:
speak(tail, agent_id)
conv.add_assistant(full_text)
return result 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 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 .audio import record
from .tts import speak, stop_speaking 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
# Персистентные сессии — одна на день для каждого агента WAKE_THRESHOLD = float(os.getenv("WAKE_THRESHOLD", "0.5"))
_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
def _handle_reset(text: str, agent_id: str) -> bool: def _handle_reset(text: str, agent_id: str) -> bool:
"""Проверяет команду сброса. Возвращает True если сброс произошёл.""" """Команда сброса. В зависимости от backend:
if is_reset_command(text): - claude: удаляет локальный файл истории
_sessions[agent_id] = Conversation(agent_id=agent_id) - 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 = "Начинаю новую сессию." msg = "Начинаю новую сессию."
print(f"🔄 {msg}") print(f"🔄 {msg}")
# Отправляем как response event — tablet зачитает, локально говорим только если TTS на этой машине.
notifier.response(msg, agent_id)
if notifier.speak_locally():
speak(msg, agent_id) speak(msg, agent_id)
return True return True
return False
def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"): def _conversation_loop(agent_id: str, agent_name: str = "Cosmo"):
"""Основной цикл диалога — слушает и отвечает пока пользователь говорит. """Основной цикл диалога.
Выходит когда в течение MAX_DURATION не было речи.""" Первая запись — с большим таймаутом (MAX_DURATION), дальше — короткий FOLLOWUP_TIMEOUT.
conv = _get_session(agent_id) Между итерациями шлём listening-event чтобы планшет показывал что всё ещё ждём."""
first = True
while 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: if not text:
print(f"😴 Тишина, жду активации...\n") print("😴 Тишина, жду активации...\n")
notifier.idle()
return return
print(f"📝 Ты → {agent_name}: {text}") print(f"📝 Ты → {agent_name}: {text}")
notifier.command(text, agent_id)
if _handle_reset(text, agent_id): if _handle_reset(text, agent_id):
conv = _get_session(agent_id)
continue 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") print(f"🤖 {agent_name}: {response}\n")
# после ответа — следующая итерация с новым record() notifier.response(response, agent_id)
# record() сам гасит эхо через ECHO_WARMUP
def run_with_enter(): def run_with_enter():
print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)") print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)")
if LLM_BACKEND == "claude":
print(f" LLM : Claude (direct)")
else:
print(f" Gateway : {GATEWAY_URL}") print(f" Gateway : {GATEWAY_URL}")
print(f" Агент : {AGENT}")
print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n") print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n")
while True: while True:
try: try:
input("⏎ Нажми Enter и говори...") input("⏎ Нажми Enter и говори...")
stop_speaking() # barge-in stop_speaking() # barge-in
notifier.wake("cosmo")
_conversation_loop("cosmo", "Cosmo") _conversation_loop("cosmo", "Cosmo")
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -95,8 +121,6 @@ def run_with_porcupine():
input=True, frames_per_buffer=1280) input=True, frames_per_buffer=1280)
print("✅ Слушаю через OpenWakeWord...") print("✅ Слушаю через OpenWakeWord...")
print("\nСкажи 'Космо'...\n")
# print("\nСкажи 'Космо' или 'Люся'...\n") # TODO: после подключения Люси
try: try:
while True: while True:
@@ -108,8 +132,10 @@ def run_with_porcupine():
if cosmo_score > 0.1: if cosmo_score > 0.1:
print(f"PREDICTION cosmo: {cosmo_score:.3f}") print(f"PREDICTION cosmo: {cosmo_score:.3f}")
if cosmo_score > 0.5: if cosmo_score > WAKE_THRESHOLD:
print("✅ Услышал 'Космо'!") print("✅ Услышал 'Космо'!")
stop_speaking() # на случай если TTS ещё играет
notifier.wake("cosmo")
stream.stop_stream() stream.stop_stream()
_conversation_loop("cosmo", "Cosmo") _conversation_loop("cosmo", "Cosmo")
cosmo_model.reset() cosmo_model.reset()
@@ -118,10 +144,8 @@ def run_with_porcupine():
# TODO: Люся — раскомментировать когда модель готова # TODO: Люся — раскомментировать когда модель готова
# lusya_score = lusya_model.predict(pcm)["lusya"] # lusya_score = lusya_model.predict(pcm)["lusya"]
# if lusya_score > 0.1: # if lusya_score > WAKE_THRESHOLD:
# print(f"PREDICTION lusya: {lusya_score:.3f}") # stop_speaking()
# if lusya_score > 0.5:
# print("✅ Услышала 'Люся'!")
# stream.stop_stream() # stream.stop_stream()
# _conversation_loop("lusya", "Люся") # _conversation_loop("lusya", "Люся")
# lusya_model.reset() # lusya_model.reset()

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 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 = [ UNIT_SLASH = [
@@ -14,6 +88,8 @@ UNIT_SLASH = [
def clean_for_speech(text: str) -> str: def clean_for_speech(text: str) -> str:
# убрать эмодзи
text = re.sub(r'[𐀀-􏿿☀-➿🌀-🧿]', '', text, flags=re.UNICODE)
text = re.sub(r'\*+', '', text) # убрать **жирный** text = re.sub(r'\*+', '', text) # убрать **жирный**
text = re.sub(r'#+\s', '', text) # убрать ## заголовки text = re.sub(r'#+\s', '', text) # убрать ## заголовки
text = re.sub(r'- ', '', 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'(^|\s)-(\d)', r'\1минус \2', text)
text = re.sub(r'±(\d)', r'плюс-минус \1', 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" # дроби и отношения "12/15" → "12 из 15", "5/10" → "5 из 10"
text = re.sub(r'(\d+)\s*/\s*(\d+)', r'\1 из \2', text) 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 os
import sys
import subprocess import subprocess
import threading import threading
from elevenlabs import VoiceSettings from elevenlabs import VoiceSettings
from .config import AUDIO_SINK, AGENTS, log from .config import (
AUDIO_SINK, AGENTS, log,
BARGE_IN_ENABLED, BARGE_IN_THRESHOLD, BARGE_IN_WARMUP,
)
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "") ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "")
ELEVENLABS_MODEL = os.getenv("ELEVENLABS_MODEL", "eleven_flash_v2_5") ELEVENLABS_MODEL = os.getenv("ELEVENLABS_MODEL", "eleven_flash_v2_5")
@@ -41,7 +43,6 @@ def is_speaking() -> bool:
def _mpv_cmd() -> list[str]: def _mpv_cmd() -> list[str]:
"""Команда mpv для воспроизведения из stdin"""
mpv_bin = os.getenv("MPV_PATH", "mpv") mpv_bin = os.getenv("MPV_PATH", "mpv")
cmd = [mpv_bin, "--no-video", "--really-quiet", "--no-terminal"] cmd = [mpv_bin, "--no-video", "--really-quiet", "--no-terminal"]
if AUDIO_SINK: if AUDIO_SINK:
@@ -50,13 +51,19 @@ def _mpv_cmd() -> list[str]:
return cmd return cmd
def speak(text: str, agent_id: str = "cosmo"): def speak(text: str, agent_id: str = "cosmo") -> bool:
"""Озвучивает text. Если BARGE_IN_ENABLED — слушает мик и может прерваться.
Возвращает True если был прерван голосом."""
try: try:
if BARGE_IN_ENABLED:
return _speak_with_barge_in(text, agent_id)
_speak_elevenlabs(text, agent_id) _speak_elevenlabs(text, agent_id)
return False
except Exception as e: except Exception as e:
log.exception("TTS ошибка") log.exception("TTS ошибка")
print(f"⚠️ Ошибка воспроизведения: {e}") print(f"⚠️ Ошибка воспроизведения: {e}")
play_error_sound() play_error_sound()
return False
def _speak_elevenlabs(text: str, agent_id: str): def _speak_elevenlabs(text: str, agent_id: str):
@@ -70,19 +77,20 @@ def _speak_elevenlabs(text: str, agent_id: str):
return return
voice_settings = VoiceSettings( voice_settings = VoiceSettings(
stability=0.65, # ниже = живее интонация (для multilingual_v2) stability=0.4,
similarity_boost=0.6, similarity_boost=0.8,
style=0.45, # выше = эмоциональнее style=0.1,
use_speaker_boost=True, use_speaker_boost=True,
speed=1.05 speed=1.1,
) )
audio_stream = client.text_to_speech.convert( audio_stream = client.text_to_speech.convert(
text=text, text=text,
voice_id=voice_id, voice_id=voice_id,
model_id=ELEVENLABS_MODEL, model_id=ELEVENLABS_MODEL,
output_format="mp3_44100_128", output_format="mp3_22050_32",
voice_settings=voice_settings voice_settings=voice_settings,
optimize_streaming_latency=3,
) )
with _process_lock: with _process_lock:
@@ -110,9 +118,74 @@ def _speak_elevenlabs(text: str, agent_id: str):
_current_process = None _current_process = None
def _speak_with_barge_in(text: str, agent_id: str) -> bool:
"""Запускает TTS в фоновом потоке и параллельно слушает мик через VAD.
Если обнаружена сильная речь — прерывает TTS. Возвращает True если прервали."""
t = threading.Thread(target=_speak_elevenlabs, args=(text, agent_id), daemon=True)
t.start()
interrupted = _listen_for_barge_in(lambda: t.is_alive())
t.join()
return interrupted
def _listen_for_barge_in(still_alive) -> bool:
"""Ждёт речь на входе пока still_alive() == True. Возвращает True если прервал."""
import pyaudio
import numpy as np
try:
import webrtcvad
vad = webrtcvad.Vad(3) # максимум агрессивности — меньше ложных на эхо
except Exception:
vad = None
SR = 16000
FRAME_MS = 30
FRAME_SAMPLES = int(SR * FRAME_MS / 1000)
warmup_frames = int(BARGE_IN_WARMUP * 1000 / FRAME_MS)
required_speech_frames = 8 # ~240 мс подряд
try:
audio = pyaudio.PyAudio()
stream = audio.open(format=pyaudio.paInt16, channels=1, rate=SR,
input=True, frames_per_buffer=FRAME_SAMPLES)
except Exception as e:
log.warning(f"Barge-in: не открылся мик: {e}")
return False
interrupted = False
speech_streak = 0
i = 0
try:
while still_alive():
data = stream.read(FRAME_SAMPLES, exception_on_overflow=False)
i += 1
if i < warmup_frames:
continue
amplitude = float(np.abs(np.frombuffer(data, dtype=np.int16)).mean())
if amplitude < BARGE_IN_THRESHOLD:
speech_streak = 0
continue
if vad is None or vad.is_speech(data, SR):
speech_streak += 1
if speech_streak >= required_speech_frames:
print(f"✋ Barge-in: слышу речь ({amplitude:.0f}), прерываю TTS")
stop_speaking()
interrupted = True
break
else:
speech_streak = 0
except Exception:
log.exception("Barge-in ошибка")
finally:
try:
stream.stop_stream()
audio.terminate()
except Exception:
pass
return interrupted
def _play_sound_file(filename: str, wait: bool = False): def _play_sound_file(filename: str, wait: bool = False):
"""Воспроизводит файл из папки sounds/ через mpv.
wait=True — блокирует до конца воспроизведения."""
sounds_dir = os.path.join(os.path.dirname(__file__), "..", "sounds") sounds_dir = os.path.join(os.path.dirname(__file__), "..", "sounds")
path = os.path.normpath(os.path.join(sounds_dir, filename)) path = os.path.normpath(os.path.join(sounds_dir, filename))
mpv_bin = os.getenv("MPV_PATH", "mpv") mpv_bin = os.getenv("MPV_PATH", "mpv")
@@ -124,7 +197,6 @@ def _play_sound_file(filename: str, wait: bool = False):
def play_activation_sound(): def play_activation_sound():
"""Звук активации — неблокирующий"""
try: try:
_play_sound_file("Success_Cosmo.mp3", wait=False) _play_sound_file("Success_Cosmo.mp3", wait=False)
except Exception as e: except Exception as e:
@@ -132,7 +204,6 @@ def play_activation_sound():
def play_error_sound(): def play_error_sound():
"""Звук ошибки — 'не получилось'"""
try: try:
_play_sound_file("Error_Cosmo.mp3") _play_sound_file("Error_Cosmo.mp3")
except Exception as e: except Exception as e: