commit 7ca8268b78ddeef782961f97e16ebbe774582b56 Author: Daniil Klimov Date: Sun Apr 12 13:34:08 2026 +0300 Initial commit: Cosmo Voice Satellite Two-agent voice assistant (Cosmo + Люся) via OpenClaw Gateway. Streaming STT (Groq) + LLM + TTS (ElevenLabs) pipeline with keep-alive sessions, barge-in, and daily conversation sessions. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9055d42 --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# OpenClaw Gateway — Cosmo +GATEWAY_URL=http://192.168.31.103:18789 +GATEWAY_TOKEN=your_openclaw_token_here +AGENT=openclaw/main +VOICE_MODEL=openai/gpt-5.4-mini + +# OpenClaw Gateway — Люся +LUSYA_GATEWAY_URL=http://192.168.31.103:18790 +LUSYA_GATEWAY_TOKEN=your_openclaw_token_here +LUSYA_AGENT=openclaw/main +LUSYA_VOICE_MODEL=openai/gpt-5.4-mini + +# STT (Groq) +STT_PROVIDER=groq +GROQ_API_KEY=your_groq_api_key_here +WHISPER_MODEL=small +WHISPER_LANGUAGE=ru + +# Picovoice Porcupine (wake word, только на Pi) +PORCUPINE_KEY=your_picovoice_key_here +WAKE_WORD_COSMO=cosmo_raspberry-pi.ppn +WAKE_WORD_LUSYA=lusya_raspberry-pi.ppn + +# Audio (на Pi: bluez_sink.XX_XX_XX_XX_XX_XX.a2dp_sink) +AUDIO_SINK= + +# TTS (ElevenLabs) +ELEVENLABS_API_KEY=your_elevenlabs_api_key_here +ELEVENLABS_MODEL=eleven_flash_v2_5 +COSMO_TTS_VOICE=your_cosmo_voice_id +LUSYA_TTS_VOICE=your_lusya_voice_id + +# VAD +SILENCE_THRESHOLD=500 +SILENCE_DURATION=1.5 +MAX_DURATION=15 +FOLLOWUP_TIMEOUT=8 + +# Логирование +LOG_FILE=errors.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abf67af --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Secrets +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ + +# Porcupine wake word models (платформо-специфичные) +*.ppn + +# Logs +*.log +errors.log + +# IDE +.vscode/ +.idea/ +*.swp +.DS_Store + +# OS +Thumbs.db + +# Claude Code +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..12da86d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# Cosmo Voice Satellite + +Голосовой ассистент дома — аналог Алисы через OpenClaw. Два агента: **Cosmo** (владельца) и **Люся** (жены). Каждый активируется своим wake word и идёт на свой OpenClaw gateway. + +## Архитектура + +``` +┌─────────────┐ wake word ┌──────────────┐ STT (Groq) +│ Microphone │ ───────────► │ Satellite │ ──────────────► +└─────────────┘ └──────────────┘ │ + │ ▼ + │ ┌──────────────┐ + │ │ OpenClaw │ + │ │ Gateway │ + │ │ (N100 PC) │ + │ stream response └──────────────┘ + ▼ │ + ┌──────────────┐ │ + │ ElevenLabs │ ◄─────────────────┘ + │ TTS │ + └──────────────┘ + │ + ▼ mp3 stream + ┌──────────────┐ + │ mpv │ → speakers (BT) + └──────────────┘ +``` + +## Инфраструктура + +- **Сервер**: N100 Mini-PC, `192.168.31.103`, Proxmox +- **Cosmo Gateway**: порт `18789`, агент `openclaw/main` +- **Люся Gateway**: порт `18790`, агент `openclaw/wife` +- **Модель**: `openai/gpt-5.4-mini` (через `x-openclaw-model` header) +- **STT**: Groq API, `whisper-large-v3-turbo`, язык ru +- **TTS**: ElevenLabs, `eleven_flash_v2_5` (~75ms латентность) +- **Wake word**: Porcupine (на Pi), Enter (при разработке) + +## Структура проекта + +``` +home-voice-assistant/ +├── .env # секреты (не в git) +├── .env.example # шаблон +├── requirements.txt +├── satellite.py # обёртка для запуска +├── satellite/ +│ ├── __init__.py +│ ├── __main__.py # entry: python -m satellite [--wake] +│ ├── config.py # env, AGENTS dict, keep-alive sessions +│ ├── text.py # clean_for_speech, find_sentence_end +│ ├── stt.py # transcribe (Groq, BytesIO, без temp файла) +│ ├── audio.py # record, record_with_timeout (VAD) +│ ├── tts.py # ElevenLabs streaming через mpv, barge-in +│ ├── llm.py # ask_agent_stream, Conversation (history) +│ └── modes.py # run_with_enter, run_with_porcupine +└── deploy/ + ├── setup.sh # установка на Raspberry Pi + └── cosmo-satellite.service # systemd unit +``` + +## Что важно знать + +### Сессии диалога +- **Одна сессия на день** для каждого агента. Это осознанное решение: каждая новая сессия в OpenClaw тяжёлая (чтение памяти, большой контекст). +- История хранится в `Conversation.messages[]` на клиенте и отправляется целиком с каждым запросом (stateless к серверу). +- Сброс сессии: фраза "начни новую сессию" / "сбрось историю" / "очисти контекст" — паттерны в `RESET_PATTERNS` в `llm.py`. +- Автосброс при смене даты (`Conversation.is_expired()`). +- `MAX_HISTORY=20` — лимит сообщений, чтобы не раздувать контекст. + +### Оптимизации скорости (все уже внедрены) +1. **Keep-alive HTTP сессии** (`requests.Session()`) — в `config.py._make_session()`, переиспользуется TCP/TLS. +2. **Streaming TTS** — ElevenLabs аудио пайпится в `mpv` через stdin, играет пока генерируется. +3. **STT без диска** — PCM → WAV в `BytesIO` → Groq, без temp файлов. +4. **Barge-in** — `stop_speaking()` вызывается при каждой активации, убивает текущий mpv процесс. + +### Роутинг по wake word +В `modes.py::run_with_porcupine` Porcupine грузит оба wake word: +- index 0 = Cosmo → `AGENTS["cosmo"]` (:18789) +- index 1 = Люся → `AGENTS["lusya"]` (:18790) + +Каждый агент имеет свой `tts_voice` в ElevenLabs. + +### Ошибки не должны ронять сервис +Каждый слой (stt, tts, llm, audio, modes) ловит `Exception` и пишет в `errors.log` через `config.log`. Верхний уровень в modes.py ловит всё непредвиденное и продолжает цикл. + +## Запуск + +### macOS / Windows (разработка) +```bash +python -m venv .venv +# macOS/Linux: source .venv/bin/activate +# Windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env # заполнить ключи + +python satellite.py # режим Enter (без wake word) +python satellite.py --wake # режим Porcupine (нужны .ppn + PORCUPINE_KEY) +``` + +### Raspberry Pi (продакшн) +```bash +sudo bash deploy/setup.sh +# далее: +sudo systemctl start cosmo-satellite +sudo journalctl -u cosmo-satellite -f +``` + +## Зависимости системы + +- **Python 3.12+** +- **portaudio** — для `pyaudio` (`brew install portaudio` / `apt install portaudio19-dev`) +- **mpv** — для воспроизведения TTS (`brew install mpv` / `apt install mpv`) +- **ffmpeg** — опционально, для совместимости форматов + +### Windows +- Python 3.12+ с pip +- `pip install pyaudio` — обычно работает через колеса pipwin или pre-built wheels. Если нет — `pip install pipwin && pipwin install pyaudio` +- mpv: скачать с [mpv.io](https://mpv.io/installation/), положить `mpv.exe` в PATH +- Porcupine работает и на Windows — wake word модель нужна под платформу windows (качать отдельную `.ppn`) + +## Переменные окружения + +Все в `.env`. Ключевые: + +| Переменная | Что | +|-----------|-----| +| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | URL OpenClaw gateway на N100 | +| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Токены авторизации | +| `AGENT`, `LUSYA_AGENT` | Имя агента в OpenClaw (`openclaw/main`, `openclaw/wife`) | +| `VOICE_MODEL`, `LUSYA_VOICE_MODEL` | Модель LLM для голоса | +| `GROQ_API_KEY` | Groq для STT | +| `ELEVENLABS_API_KEY` | ElevenLabs TTS | +| `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | Voice ID в ElevenLabs | +| `ELEVENLABS_MODEL` | `eleven_flash_v2_5` (быстрый) | +| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink`. На Mac/Win: пусто. | +| `PORCUPINE_KEY`, `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Только для `--wake` режима | +| `SILENCE_THRESHOLD=500` | VAD: чувствительность (ниже = ловит тихую речь) | +| `SILENCE_DURATION=1.5` | Сек тишины = конец фразы | +| `FOLLOWUP_TIMEOUT=8` | Сек ожидания продолжения диалога | +| `MAX_HISTORY=20` | Макс. сообщений в сессии | + +## Частые задачи + +**Сменить голос у агента**: меняй `COSMO_TTS_VOICE` / `LUSYA_TTS_VOICE` в `.env`. Voice ID берётся на [elevenlabs.io/app/voice-library](https://elevenlabs.io/app/voice-library). + +**Отладить VAD (ассистент не слышит / слушает слишком долго)**: `SILENCE_THRESHOLD` (громкость) и `SILENCE_DURATION` (сек). + +**Добавить третьего агента**: в `config.py::AGENTS` новый ключ, в `modes.py::run_with_porcupine` добавить `WAKE_WORD_*` и `wake_word_map.append(...)`. + +**Сменить модель LLM**: `VOICE_MODEL` в `.env` — передаётся в header `x-openclaw-model`. Модель `openclaw/main` остаётся как agent (это маршрут в OpenClaw). + +## Что НЕ делать + +- Не комитить `.env` (есть в `.gitignore`) +- Не возвращать fallback на macOS `say` — проект специально унифицирован на ElevenLabs + mpv +- Не создавать новую сессию Conversation на каждую активацию — это было в старой версии, сейчас одна сессия на день +- Не добавлять temp файлы для WAV/mp3 — всё идёт через `BytesIO` / stdin pipe + +## Roadmap + +- [ ] Speaker identification (определять кто говорит без разных wake words) +- [ ] Проактивные уведомления (WebSocket от сервера → satellite сам начинает говорить) +- [ ] Контекст окружения в system prompt (время, погода, состояние устройств) +- [ ] Real-time barge-in (прерывание по голосу во время озвучки, не только по новой активации) diff --git a/deploy/cosmo-satellite.service b/deploy/cosmo-satellite.service new file mode 100644 index 0000000..f458614 --- /dev/null +++ b/deploy/cosmo-satellite.service @@ -0,0 +1,27 @@ +[Unit] +Description=Cosmo Voice Satellite +After=network-online.target bluetooth.target pulseaudio.service +Wants=network-online.target + +[Service] +Type=simple +User=daniil +WorkingDirectory=/home/daniil/home-voice-assistant +ExecStart=/home/daniil/home-voice-assistant/.venv/bin/python -m satellite --wake +Restart=always +RestartSec=5 + +# Env +EnvironmentFile=/home/daniil/home-voice-assistant/.env + +# Audio — доступ к PulseAudio/PipeWire +Environment=XDG_RUNTIME_DIR=/run/user/1000 +Environment=PULSE_SERVER=unix:/run/user/1000/pulse/native + +# Логи в journalctl +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cosmo + +[Install] +WantedBy=multi-user.target diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100755 index 0000000..b389d6c --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# Cosmo Voice Satellite — полная установка на Raspberry Pi 5 +# Запуск: sudo bash setup.sh +# ============================================================ + +APP_DIR="/home/daniil/home-voice-assistant" +APP_USER="daniil" +SERVICE_NAME="cosmo-satellite" + +echo "========================================" +echo " Cosmo Satellite — установка на Pi 5" +echo "========================================" + +# --- 1. Системные пакеты --- +echo "" +echo "▶ 1/6 Устанавливаю системные пакеты..." +apt-get update +apt-get install -y \ + python3 \ + python3-venv \ + python3-dev \ + python3-pip \ + portaudio19-dev \ + libsndfile1 \ + pulseaudio \ + pulseaudio-module-bluetooth \ + bluez \ + bluez-tools \ + ffmpeg \ + git + +# --- 2. Python venv --- +echo "" +echo "▶ 2/6 Создаю виртуальное окружение..." +cd "$APP_DIR" +sudo -u "$APP_USER" python3 -m venv .venv +sudo -u "$APP_USER" .venv/bin/pip install --upgrade pip +sudo -u "$APP_USER" .venv/bin/pip install -r requirements.txt + +# --- 3. Проверка .env --- +echo "" +echo "▶ 3/6 Проверяю .env..." +if [ ! -f "$APP_DIR/.env" ]; then + echo "❌ Файл .env не найден! Скопируй .env на Pi перед запуском." + exit 1 +fi + +# Проверяем ключевые переменные +source "$APP_DIR/.env" +if [ -z "${GATEWAY_TOKEN:-}" ]; then + echo "❌ GATEWAY_TOKEN не задан в .env" + exit 1 +fi +if [ -z "${GROQ_API_KEY:-}" ]; then + echo "❌ GROQ_API_KEY не задан в .env" + exit 1 +fi +echo " .env OK" + +# --- 4. Bluetooth (PulseAudio) --- +echo "" +echo "▶ 4/6 Настраиваю Bluetooth audio..." +# Включаем PulseAudio для пользователя (если не systemd --user) +sudo -u "$APP_USER" systemctl --user enable pulseaudio +sudo -u "$APP_USER" systemctl --user start pulseaudio || true + +echo " Bluetooth настроен." +echo " Подключи колонку вручную:" +echo " bluetoothctl" +echo " > scan on" +echo " > pair " +echo " > connect " +echo " > trust " +echo "" +echo " После подключения найди sink:" +echo " pactl list sinks short" +echo " И пропиши AUDIO_SINK=bluez_sink.XX_XX_XX.a2dp_sink в .env" + +# --- 5. systemd сервис --- +echo "" +echo "▶ 5/6 Устанавливаю systemd сервис..." +cp "$APP_DIR/deploy/cosmo-satellite.service" /etc/systemd/system/${SERVICE_NAME}.service +systemctl daemon-reload +systemctl enable ${SERVICE_NAME} +echo " Сервис установлен: ${SERVICE_NAME}" + +# --- 6. Проверка --- +echo "" +echo "▶ 6/6 Проверяю установку..." +sudo -u "$APP_USER" "$APP_DIR/.venv/bin/python" -c " +from satellite.config import GATEWAY_URL, AGENT +print(f' Gateway : {GATEWAY_URL}') +print(f' Агент : {AGENT}') +print(' Python imports OK') +" + +echo "" +echo "========================================" +echo " Установка завершена!" +echo "========================================" +echo "" +echo "Команды:" +echo " sudo systemctl start ${SERVICE_NAME} # запустить" +echo " sudo systemctl stop ${SERVICE_NAME} # остановить" +echo " sudo systemctl restart ${SERVICE_NAME} # перезапустить" +echo " sudo journalctl -u ${SERVICE_NAME} -f # логи в реальном времени" +echo " cat ${APP_DIR}/errors.log # лог ошибок" +echo "" +echo "Не забудь:" +echo " 1. Подключить BT колонку и прописать AUDIO_SINK в .env" +echo " 2. Прописать PORCUPINE_KEY и WAKE_WORD_MODEL в .env" +echo " 3. Затем: sudo systemctl start ${SERVICE_NAME}" +echo "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef3f70a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +faster-whisper +pyaudio +requests +python-dotenv +numpy +groq +elevenlabs +# Раскомментировать когда будет Pi + Porcupine: +# pvporcupine diff --git a/satellite.py b/satellite.py new file mode 100644 index 0000000..80ccd36 --- /dev/null +++ b/satellite.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +""" +Cosmo Satellite — голосовой клиент для OpenClaw Gateway +Обёртка: запускает satellite package +""" +import sys +from satellite.modes import run_with_enter, run_with_porcupine + +if "--wake" in sys.argv: + run_with_porcupine() +else: + run_with_enter() diff --git a/satellite/__init__.py b/satellite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/satellite/__main__.py b/satellite/__main__.py new file mode 100644 index 0000000..4a50e0b --- /dev/null +++ b/satellite/__main__.py @@ -0,0 +1,13 @@ +""" +Cosmo Satellite — голосовой клиент для OpenClaw Gateway +Запуск: python -m satellite [--wake] +""" +import sys + +from .modes import run_with_enter, run_with_porcupine + +if __name__ == "__main__": + if "--wake" in sys.argv: + run_with_porcupine() + else: + run_with_enter() diff --git a/satellite/audio.py b/satellite/audio.py new file mode 100644 index 0000000..5e048a9 --- /dev/null +++ b/satellite/audio.py @@ -0,0 +1,104 @@ +import pyaudio +import numpy as np + +from .config import SILENCE_THRESHOLD, SILENCE_DURATION, MAX_DURATION, log +from .stt import transcribe + + +def record() -> str: + """Запись до тишины (VAD) + STT""" + try: + audio = pyaudio.PyAudio() + stream = audio.open( + format=pyaudio.paInt16, + channels=1, + rate=16000, + input=True, + frames_per_buffer=1024, + ) + except Exception as e: + log.exception("Не удалось открыть микрофон") + print(f"⚠️ Ошибка микрофона: {e}") + return "" + + print("🎙️ Говори...") + frames = [] + silent_chunks = 0 + speaking_started = False + max_chunks = int(16000 / 1024 * MAX_DURATION) + silence_chunks_needed = int(16000 / 1024 * SILENCE_DURATION) + + try: + for _ in range(max_chunks): + data = stream.read(1024, exception_on_overflow=False) + frames.append(data) + + amplitude = np.abs(np.frombuffer(data, dtype=np.int16)).mean() + + if amplitude > SILENCE_THRESHOLD: + speaking_started = True + silent_chunks = 0 + elif speaking_started: + silent_chunks += 1 + if silent_chunks >= silence_chunks_needed: + print("🔇 Конец речи") + break + except Exception as e: + log.exception("Ошибка при записи аудио") + print(f"⚠️ Ошибка записи: {e}") + finally: + stream.stop_stream() + audio.terminate() + + if not speaking_started: + return "" + + return transcribe(frames) + + +def record_with_timeout(timeout: float = 8.0) -> str: + """Слушает timeout секунд, возвращает пусто если речи не было""" + try: + audio = pyaudio.PyAudio() + stream = audio.open( + format=pyaudio.paInt16, + channels=1, + rate=16000, + input=True, + frames_per_buffer=1024, + ) + except Exception as e: + log.exception("Не удалось открыть микрофон (followup)") + print(f"⚠️ Ошибка микрофона: {e}") + return "" + + frames = [] + silent_chunks = 0 + speaking_started = False + max_chunks = int(16000 / 1024 * timeout) + silence_chunks_needed = int(16000 / 1024 * SILENCE_DURATION) + + try: + for _ in range(max_chunks): + data = stream.read(1024, exception_on_overflow=False) + frames.append(data) + amplitude = np.abs(np.frombuffer(data, dtype=np.int16)).mean() + + if amplitude > SILENCE_THRESHOLD: + speaking_started = True + silent_chunks = 0 + elif speaking_started: + silent_chunks += 1 + if silent_chunks >= silence_chunks_needed: + break + except Exception as e: + log.exception("Ошибка при записи аудио (followup)") + print(f"⚠️ Ошибка записи: {e}") + finally: + stream.stop_stream() + audio.terminate() + + if not speaking_started: + return "" + + return transcribe(frames) diff --git a/satellite/config.py b/satellite/config.py new file mode 100644 index 0000000..27f8a6a --- /dev/null +++ b/satellite/config.py @@ -0,0 +1,85 @@ +import os +import sys +import logging +import requests as _requests +from dotenv import load_dotenv +from groq import Groq + +load_dotenv() + +# Логгер — ошибки в файл + короткое сообщение в консоль +LOG_FILE = os.getenv("LOG_FILE", "errors.log") + +logging.basicConfig( + level=logging.WARNING, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE, encoding="utf-8"), + ], +) +log = logging.getLogger("cosmo") + +# OpenClaw Gateway — Cosmo (по умолчанию) +GATEWAY_URL = os.getenv("GATEWAY_URL", "http://192.168.31.103:18789") +GATEWAY_TOKEN = os.getenv("GATEWAY_TOKEN") +AGENT = os.getenv("AGENT", "openclaw/main") +VOICE_MODEL = os.getenv("VOICE_MODEL", "openai/gpt-4o-mini") + +# OpenClaw Gateway — Люся +LUSYA_GATEWAY_URL = os.getenv("LUSYA_GATEWAY_URL", "http://192.168.31.103:18790") +LUSYA_GATEWAY_TOKEN = os.getenv("LUSYA_GATEWAY_TOKEN", GATEWAY_TOKEN) +LUSYA_AGENT = os.getenv("LUSYA_AGENT", "openclaw/wife") +LUSYA_VOICE_MODEL = os.getenv("LUSYA_VOICE_MODEL", VOICE_MODEL) + +# Keep-alive HTTP сессии — переиспользуют TCP/TLS соединения +def _make_session(token: str) -> _requests.Session: + s = _requests.Session() + s.headers.update({ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }) + return s + + +# Конфиги агентов по имени +AGENTS = { + "cosmo": { + "name": "Cosmo", + "gateway_url": GATEWAY_URL, + "token": GATEWAY_TOKEN, + "agent": AGENT, + "voice_model": VOICE_MODEL, + "tts_voice": os.getenv("COSMO_TTS_VOICE", ""), + "session": _make_session(GATEWAY_TOKEN), + }, + "lusya": { + "name": "Люся", + "gateway_url": LUSYA_GATEWAY_URL, + "token": LUSYA_GATEWAY_TOKEN, + "agent": LUSYA_AGENT, + "voice_model": LUSYA_VOICE_MODEL, + "tts_voice": os.getenv("LUSYA_TTS_VOICE", ""), + "session": _make_session(LUSYA_GATEWAY_TOKEN), + }, +} + +# STT +STT_PROVIDER = os.getenv("STT_PROVIDER", "groq") +WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small") +WHISPER_LANG = os.getenv("WHISPER_LANGUAGE", "ru") + +# Audio (на Pi: PulseAudio BT sink) +AUDIO_SINK = os.getenv("AUDIO_SINK", "") + +# VAD +SILENCE_THRESHOLD = int(os.getenv("SILENCE_THRESHOLD", "500")) +SILENCE_DURATION = float(os.getenv("SILENCE_DURATION", "1.5")) +MAX_DURATION = int(os.getenv("MAX_DURATION", "15")) +FOLLOWUP_TIMEOUT = float(os.getenv("FOLLOWUP_TIMEOUT", "8")) + +# Groq client +groq_client = Groq(api_key=os.getenv("GROQ_API_KEY")) + +if not GATEWAY_TOKEN: + print("❌ GATEWAY_TOKEN не задан в .env") + sys.exit(1) diff --git a/satellite/llm.py b/satellite/llm.py new file mode 100644 index 0000000..c03f3cf --- /dev/null +++ b/satellite/llm.py @@ -0,0 +1,141 @@ +import json +import re +import requests +from datetime import date + +from .config import GATEWAY_URL, VOICE_MODEL, AGENT, AGENTS, log +from .text import clean_for_speech, find_sentence_end +from .tts import speak + +SYSTEM_PROMPT = "Отвечай кратко, 1-2 предложения, без markdown, без эмодзи." +MAX_HISTORY = int(__import__("os").getenv("MAX_HISTORY", "20")) + +RESET_PATTERNS = re.compile( + r"(начни|начать|создай|открой|давай).{0,10}(новую|новый|чистую|чистый).{0,10}(сессию|сессия|диалог|разговор|чат)" + r"|" + r"(сбрось|очисти|обнови).{0,10}(сессию|диалог|разговор|чат|историю|контекст)", + re.IGNORECASE, +) + + +class Conversation: + """Хранит историю сообщений — одна сессия на день""" + + def __init__(self, agent_id: str = "cosmo"): + self.agent_id = agent_id + 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: + return bool(RESET_PATTERNS.search(text)) + + +def ask_agent_stream(text: str, conv: "Conversation | None" = None, agent_id: str = "cosmo") -> str: + if conv is None: + conv = Conversation(agent_id) + + conv.add_user(text) + + cfg = AGENTS.get(agent_id, AGENTS["cosmo"]) + gateway_url = cfg["gateway_url"] + session = cfg["session"] + agent = cfg["agent"] + + try: + resp = session.post( + f"{gateway_url}/v1/chat/completions", + headers={"x-openclaw-model": cfg["voice_model"]}, + json={ + "model": agent, + "stream": True, + "messages": conv.messages, + "max_tokens": 150, + }, + stream=True, + timeout=60, + ) + resp.raise_for_status() + except requests.ConnectionError: + log.exception("Gateway недоступен") + msg = "Не могу связаться с сервером, попробуй ещё раз." + print(f"⚠️ {msg}") + speak(msg, agent_id) + return msg + except requests.Timeout: + log.exception("Gateway таймаут") + msg = "Сервер не ответил вовремя, попробуй ещё раз." + print(f"⚠️ {msg}") + speak(msg, agent_id) + return msg + except requests.HTTPError: + log.exception(f"Gateway HTTP ошибка {resp.status_code}") + msg = "Ошибка сервера, попробуй ещё раз." + print(f"⚠️ Gateway {resp.status_code}: {resp.text}") + speak(msg, agent_id) + return msg + + full_text = "" + buffer = "" + + try: + for line in resp.iter_lines(): + if not line or line == b"data: [DONE]": + continue + if line.startswith(b"data: "): + try: + chunk = json.loads(line[6:]) + delta = chunk["choices"][0]["delta"].get("content", "") + if not delta: + continue + + full_text += delta + buffer += delta + + last_punct = find_sentence_end(buffer, min_len=60) + if last_punct > -1: + sentence = clean_for_speech(buffer[:last_punct + 1]) + if sentence.strip(): + print(f"🔊 Говорю: {sentence}") + speak(sentence, agent_id) + buffer = buffer[last_punct + 1:].lstrip() + + except (json.JSONDecodeError, KeyError, IndexError): + continue + except Exception as e: + log.exception("Ошибка при чтении стрима") + print(f"⚠️ Стрим прервался: {e}") + + # Остаток + if buffer.strip(): + sentence = clean_for_speech(buffer) + if sentence: + speak(sentence, agent_id) + + if not full_text: + msg = "Не получил ответ, попробуй ещё раз." + speak(msg, agent_id) + return msg + + result = clean_for_speech(full_text) + conv.add_assistant(full_text) + return result diff --git a/satellite/modes.py b/satellite/modes.py new file mode 100644 index 0000000..eb9a9d8 --- /dev/null +++ b/satellite/modes.py @@ -0,0 +1,170 @@ +import os +import sys + +from .config import GATEWAY_URL, AGENT, FOLLOWUP_TIMEOUT, log +from .audio import record, record_with_timeout +from .tts import play_activation_sound, speak, stop_speaking +from .llm import ask_agent_stream, Conversation, is_reset_command + +# Персистентные сессии — одна на день для каждого агента +_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: + """Проверяет команду сброса. Возвращает True если сброс произошёл.""" + if is_reset_command(text): + _sessions[agent_id] = Conversation(agent_id=agent_id) + msg = "Начинаю новую сессию." + print(f"🔄 {msg}") + speak(msg, agent_id) + return True + return False + + +def run_with_enter(): + print("\n🦞 Cosmo Satellite запущен (режим: Enter для активации)") + print(f" Gateway : {GATEWAY_URL}") + print(f" Агент : {AGENT}") + print("\nНажми Enter → говори → получи ответ. Ctrl+C для выхода.\n") + + while True: + try: + input("⏎ Нажми Enter и говори...") + stop_speaking() # barge-in: прервать если ещё говорит + play_activation_sound() + + conv = _get_session("cosmo") + + while True: + text = record() + if not text: + print("⚠️ Ничего не распознано") + break + + print(f"📝 Ты: {text}") + + if _handle_reset(text, "cosmo"): + conv = _get_session("cosmo") + break + + response = ask_agent_stream(text, conv=conv) + print(f"🤖 Cosmo: {response}\n") + + print(f"👂 Слушаю продолжение ({int(FOLLOWUP_TIMEOUT)} сек)...") + followup = record_with_timeout(timeout=FOLLOWUP_TIMEOUT) + + if not followup: + print("😴 Нет продолжения, жду активации...\n") + break + + text = followup + + except KeyboardInterrupt: + print("\n👋 Выход") + break + except Exception as e: + log.exception("Непредвиденная ошибка в цикле Enter") + print(f"⚠️ Ошибка: {e} — продолжаю работу...\n") + + +def run_with_porcupine(): + """Режим продакшн — два wake word через Porcupine (для Pi)""" + import pvporcupine + import struct + + from .config import AGENTS + + porcupine_key = os.getenv("PORCUPINE_KEY") + wake_word_cosmo = os.getenv("WAKE_WORD_COSMO") + wake_word_lusya = os.getenv("WAKE_WORD_LUSYA") + + if not porcupine_key: + print("❌ PORCUPINE_KEY не задан в .env") + sys.exit(1) + + keyword_paths = [] + wake_word_map = [] + + if wake_word_cosmo: + keyword_paths.append(wake_word_cosmo) + wake_word_map.append("cosmo") + if wake_word_lusya: + keyword_paths.append(wake_word_lusya) + wake_word_map.append("lusya") + + if not keyword_paths: + print("❌ WAKE_WORD_COSMO или WAKE_WORD_LUSYA не заданы в .env") + sys.exit(1) + + import pyaudio + + porcupine = pvporcupine.create( + access_key=porcupine_key, + keyword_paths=keyword_paths, + ) + + audio = pyaudio.PyAudio() + stream = audio.open( + rate=porcupine.sample_rate, + channels=1, + format=pyaudio.paInt16, + input=True, + frames_per_buffer=porcupine.frame_length, + ) + + print("\n🦞 Cosmo Satellite запущен (режим: wake word)") + for agent_id in wake_word_map: + cfg = AGENTS[agent_id] + print(f" {cfg['name']:6s} : {cfg['gateway_url']} → {cfg['agent']}") + print(f"\nСкажи 'Космо' или 'Люся'...\n") + + try: + while True: + try: + pcm = stream.read(porcupine.frame_length) + pcm = struct.unpack_from("h" * porcupine.frame_length, pcm) + + keyword_index = porcupine.process(pcm) + if keyword_index >= 0: + agent_id = wake_word_map[keyword_index] + agent_name = AGENTS[agent_id]["name"] + stop_speaking() # barge-in: прервать если ещё говорит + print(f"✅ Услышал '{agent_name}'!") + play_activation_sound() + + conv = _get_session(agent_id) + + text = record() + if not text: + continue + + print(f"📝 Ты → {agent_name}: {text}") + + if _handle_reset(text, agent_id): + continue + + response = ask_agent_stream(text, conv=conv, agent_id=agent_id) + print(f"🤖 {agent_name}: {response}\n") + + except KeyboardInterrupt: + raise + except Exception as e: + log.exception("Непредвиденная ошибка в цикле Porcupine") + print(f"⚠️ Ошибка: {e} — продолжаю слушать...\n") + + except KeyboardInterrupt: + print("\n👋 Выход") + finally: + stream.stop_stream() + audio.terminate() + porcupine.delete() diff --git a/satellite/stt.py b/satellite/stt.py new file mode 100644 index 0000000..a3909b6 --- /dev/null +++ b/satellite/stt.py @@ -0,0 +1,55 @@ +import io +import wave + +from .config import groq_client, STT_PROVIDER, WHISPER_MODEL, WHISPER_LANG, log + + +def transcribe_groq_bytes(wav_bytes: bytes) -> str: + """Отправляет WAV байты в Groq без записи на диск""" + buf = io.BytesIO(wav_bytes) + buf.name = "audio.wav" + result = groq_client.audio.transcriptions.create( + file=buf, + model="whisper-large-v3-turbo", + language="ru", + ) + return result.text + + +def frames_to_wav(frames: list[bytes]) -> bytes: + """Конвертирует сырые PCM фреймы в WAV в памяти""" + buf = io.BytesIO() + wf = wave.open(buf, "wb") + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"".join(frames)) + wf.close() + return buf.getvalue() + + +def transcribe(frames: list[bytes]) -> str: + """Транскрибирует аудио фреймы — всё в памяти, без диска""" + try: + wav_bytes = frames_to_wav(frames) + + if STT_PROVIDER == "groq": + return transcribe_groq_bytes(wav_bytes) + + # Whisper fallback — нужен файл на диске + import tempfile + import os + from faster_whisper import WhisperModel + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + f.write(wav_bytes) + tmp_path = f.name + try: + model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8") + segments, _ = model.transcribe(tmp_path, language=WHISPER_LANG) + return " ".join(s.text for s in segments).strip() + finally: + os.unlink(tmp_path) + except Exception as e: + log.exception("STT ошибка") + print(f"⚠️ Ошибка распознавания речи: {e}") + return "" diff --git a/satellite/text.py b/satellite/text.py new file mode 100644 index 0000000..6a189ff --- /dev/null +++ b/satellite/text.py @@ -0,0 +1,67 @@ +import re + + +def clean_for_speech(text: str) -> str: + text = re.sub(r'\*+', '', text) # убрать **жирный** + text = re.sub(r'#+\s', '', text) # убрать ## заголовки + text = re.sub(r'- ', '', text) # убрать тире списков + text = re.sub(r'\[.*?\]\(.*?\)', '', text) # убрать ссылки + text = re.sub(r'\n+', '. ', text) # переносы → точки + text = re.sub(r'\s+', ' ', text) # лишние пробелы + text = re.sub(r'(\d+)\.(\s)', r'\1\2', text) + return text.strip() + + +def find_sentence_end(text: str, min_len: int = 60) -> int: + """Ищет конец предложения, игнорируя ложные точки""" + if len(text) < min_len: + return -1 + + for match in re.finditer(r'[.!?]', text): + pos = match.start() + if pos < min_len: + continue + + before_1 = text[max(0, pos-1):pos] # 1 символ до + before_3 = text[max(0, pos-3):pos] # 3 символа до + after_2 = text[pos+1:pos+3] # 2 символа после + after_stripped = after_2.lstrip() + + # 1. Цифра.Цифра → "0.76", "3.14" + if before_1.isdigit() and after_2[:1].isdigit(): + continue + + # 2. Цифра. Цифра → "1. 2 ГБ" + if before_1.isdigit() and after_stripped[:1].isdigit(): + continue + + # 3. Аббревиатуры → "ГБ.", "МБ.", "км.", "шт.", "руб.", "млн.", "млрд." + abbrevs = ["гб", "мб", "кб", "тб", "км", "см", "мм", "шт", + "руб", "млн", "млрд", "тыс", "кг", "гр", "мл", + "gb", "mb", "kb", "tb", "km", "ms", "kb"] + if any(before_3.lower().endswith(a) for a in abbrevs): + continue + + # 4. Одиночная заглавная буква → "А.", "В.", "США." (инициалы/аббр.) + if len(before_3.strip()) == 1 and before_3.strip().isupper(): + continue + + # 5. После точки строчная буква → "load avg. нормально" + if after_stripped and after_stripped[0].islower(): + continue + + # 6. Многоточие → "..." + if text[pos:pos+3] == "...": + continue + + # 7. Точка внутри URL или IP → "192.168.1.1", "example.com" + if before_1.isdigit() or (after_2[:1].isdigit() and "." in before_3): + continue + + # 8. Процент с точкой → "95.5%" + if "%" in after_2[:2]: + continue + + return pos + + return -1 diff --git a/satellite/tts.py b/satellite/tts.py new file mode 100644 index 0000000..bc8f694 --- /dev/null +++ b/satellite/tts.py @@ -0,0 +1,110 @@ +import os +import sys +import subprocess +import threading + +from .config import AUDIO_SINK, AGENTS, log + +ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY", "") +ELEVENLABS_MODEL = os.getenv("ELEVENLABS_MODEL", "eleven_flash_v2_5") + +_elevenlabs_client = None +_current_process: subprocess.Popen | None = None +_process_lock = threading.Lock() + + +def _get_elevenlabs(): + global _elevenlabs_client + if _elevenlabs_client is None: + from elevenlabs.client import ElevenLabs + _elevenlabs_client = ElevenLabs(api_key=ELEVENLABS_API_KEY) + return _elevenlabs_client + + +def stop_speaking(): + """Прерывает текущее воспроизведение (barge-in)""" + global _current_process + with _process_lock: + if _current_process and _current_process.poll() is None: + _current_process.terminate() + try: + _current_process.wait(timeout=1) + except subprocess.TimeoutExpired: + _current_process.kill() + _current_process = None + + +def is_speaking() -> bool: + with _process_lock: + return _current_process is not None and _current_process.poll() is None + + +def _mpv_cmd() -> list[str]: + """Команда mpv для воспроизведения из stdin""" + cmd = ["mpv", "--no-video", "--really-quiet", "--no-terminal"] + if AUDIO_SINK: + cmd.append(f"--audio-device=pulse/{AUDIO_SINK}") + cmd.append("-") + return cmd + + +def speak(text: str, agent_id: str = "cosmo"): + try: + _speak_elevenlabs(text, agent_id) + except Exception as e: + log.exception("TTS ошибка") + print(f"⚠️ Ошибка воспроизведения: {e}") + + +def _speak_elevenlabs(text: str, agent_id: str): + global _current_process + client = _get_elevenlabs() + voice_id = AGENTS.get(agent_id, AGENTS["cosmo"]).get("tts_voice", "") + + if not voice_id: + log.error(f"tts_voice не задан для {agent_id}") + print(f"⚠️ tts_voice не задан для {agent_id}") + return + + audio_stream = client.text_to_speech.convert( + text=text, + voice_id=voice_id, + model_id=ELEVENLABS_MODEL, + output_format="mp3_44100_128", + ) + + with _process_lock: + _current_process = subprocess.Popen( + _mpv_cmd(), stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + proc = _current_process + + try: + for chunk in audio_stream: + if proc.poll() is not None: + break + try: + proc.stdin.write(chunk) + except BrokenPipeError: + break + proc.stdin.close() + proc.wait() + except Exception: + proc.kill() + finally: + with _process_lock: + if _current_process is proc: + _current_process = None + + +def play_activation_sound(): + """Звук активации после wake word""" + try: + if sys.platform == "darwin": + subprocess.run(["afplay", "/System/Library/Sounds/Glass.aiff"]) + else: + subprocess.run(["paplay", "/usr/share/sounds/freedesktop/stereo/bell.oga"]) + except Exception as e: + log.exception("Ошибка звука активации") + print(f"⚠️ Ошибка звука: {e}")