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) <noreply@anthropic.com>
This commit is contained in:
40
.env.example
Normal file
40
.env.example
Normal file
@@ -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
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -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/
|
||||
165
CLAUDE.md
Normal file
165
CLAUDE.md
Normal file
@@ -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 (прерывание по голосу во время озвучки, не только по новой активации)
|
||||
27
deploy/cosmo-satellite.service
Normal file
27
deploy/cosmo-satellite.service
Normal file
@@ -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
|
||||
116
deploy/setup.sh
Executable file
116
deploy/setup.sh
Executable file
@@ -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 <MAC>"
|
||||
echo " > connect <MAC>"
|
||||
echo " > trust <MAC>"
|
||||
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 ""
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
faster-whisper
|
||||
pyaudio
|
||||
requests
|
||||
python-dotenv
|
||||
numpy
|
||||
groq
|
||||
elevenlabs
|
||||
# Раскомментировать когда будет Pi + Porcupine:
|
||||
# pvporcupine
|
||||
12
satellite.py
Normal file
12
satellite.py
Normal file
@@ -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()
|
||||
0
satellite/__init__.py
Normal file
0
satellite/__init__.py
Normal file
13
satellite/__main__.py
Normal file
13
satellite/__main__.py
Normal file
@@ -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()
|
||||
104
satellite/audio.py
Normal file
104
satellite/audio.py
Normal file
@@ -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)
|
||||
85
satellite/config.py
Normal file
85
satellite/config.py
Normal file
@@ -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)
|
||||
141
satellite/llm.py
Normal file
141
satellite/llm.py
Normal file
@@ -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
|
||||
170
satellite/modes.py
Normal file
170
satellite/modes.py
Normal file
@@ -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()
|
||||
55
satellite/stt.py
Normal file
55
satellite/stt.py
Normal file
@@ -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 ""
|
||||
67
satellite/text.py
Normal file
67
satellite/text.py
Normal file
@@ -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
|
||||
110
satellite/tts.py
Normal file
110
satellite/tts.py
Normal file
@@ -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}")
|
||||
Reference in New Issue
Block a user