Switch wake word from Porcupine to openwakeword + training pipeline
- Add training/ pipeline (step_1..step_5) and own-samples flow
- record_wav.py with single-shot and long-record modes, RMS-based silence filter
- remove_silent.py to drop silent samples and renumber
- modes.py: openwakeword inference with reset() and quiet predictions; commented Lusya block for later
- stt.py: drop local faster-whisper fallback, Groq-only
- config.py: remove unused STT_PROVIDER/WHISPER_*
- llm.py: replace __import__("os") hack with proper import
- tts.py: remove debug traceback in play_error_sound
- requirements.txt: add openwakeword/sounddevice/scipy, drop faster-whisper
- deploy/setup.sh: validate ELEVENLABS_API_KEY and WAKE_WORD_COSMO presence
- README.md, CLAUDE.md, project_roadmap memory updated to reflect new architecture
This commit is contained in:
10
.env.example
10
.env.example
@@ -11,15 +11,11 @@ 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
|
||||
# Wake word (openwakeword .onnx модели, обучаются через training/step_4.py)
|
||||
WAKE_WORD_COSMO=data/models/cosmo.onnx
|
||||
WAKE_WORD_LUSYA=data/models/lusya.onnx
|
||||
|
||||
# Audio (на Pi: bluez_sink.XX_XX_XX_XX_XX_XX.a2dp_sink)
|
||||
AUDIO_SINK=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ data/
|
||||
|
||||
# Training
|
||||
training/
|
||||
training/openwakeword
|
||||
|
||||
29
CLAUDE.md
29
CLAUDE.md
@@ -157,9 +157,34 @@ sudo journalctl -u cosmo-satellite -f
|
||||
- Не создавать новую сессию Conversation на каждую активацию — это было в старой версии, сейчас одна сессия на день
|
||||
- Не добавлять temp файлы для WAV/mp3 — всё идёт через `BytesIO` / stdin pipe
|
||||
|
||||
## Тренировка своего wake word
|
||||
|
||||
Пайплайн в `training/`:
|
||||
- `record_wav.py <model> <positive|negative>` — запись 16kHz mono PCM 16-bit в `training/own_samples/<model>/`
|
||||
- `training/step_1.py` … `step_5.py` — установка зависимостей, конвертация датасетов, генерация конфига, обучение, экспорт в `data/models/<name>.onnx`
|
||||
- `training/training_config.json` — параметры (`wake_word_list`, `use_own_samples`, штрафы, шаги)
|
||||
- `training/openwakeword/` — форк openwakeword, `examples/custom_model.yml` — базовый шаблон конфига
|
||||
- Под капотом: openwakeword (НЕ Porcupine, несмотря на легаси-имена в коде). Wake word работает через DNN-модель .onnx.
|
||||
|
||||
Реалистичные цифры для своего голоса: 500+ positive и 1000+ negative wav-файлов, иначе recall/FP/hour не сходятся. Negative должны включать фонетически близкие слова.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Done
|
||||
- [x] Модулизация satellite.py (audio/stt/llm/tts/modes/config)
|
||||
- [x] ElevenLabs streaming TTS + mpv pipe
|
||||
- [x] Keep-alive HTTP сессии, STT через BytesIO, barge-in
|
||||
- [x] Сессии диалога (одна на день, MAX_HISTORY, паттерны сброса)
|
||||
- [x] Пайплайн тренировки своего wake word на собственных записях
|
||||
|
||||
### In progress
|
||||
- [ ] Дообучение модели cosmo (на текущем датасете 300 pos / 117 neg метрики плохие — recall 25%, FP/hr 32). Нужно дозаписать данные.
|
||||
- [ ] Подключить Люсю в `run_with_wakeword` (сейчас грузится только модель cosmo, lusya wake word не работает)
|
||||
|
||||
### Planned
|
||||
- [ ] systemd autostart на Raspberry Pi (`deploy/cosmo-satellite.service` есть, но не проверен в проде)
|
||||
- [ ] Home Assistant tool в OpenClaw воркспейсе (управление светом/температурой через голос)
|
||||
- [ ] Real-time barge-in (прерывание по голосу во время озвучки, не только по новой активации)
|
||||
- [ ] Контекст окружения в system prompt (время, погода, состояние устройств)
|
||||
- [ ] Speaker identification (определять кто говорит без разных wake words)
|
||||
- [ ] Проактивные уведомления (WebSocket от сервера → satellite сам начинает говорить)
|
||||
- [ ] Контекст окружения в system prompt (время, погода, состояние устройств)
|
||||
- [ ] Real-time barge-in (прерывание по голосу во время озвучки, не только по новой активации)
|
||||
|
||||
173
README.md
Normal file
173
README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Cosmo Voice Satellite
|
||||
|
||||
Домашний голосовой ассистент. Слушает wake word, распознаёт речь, ходит в OpenClaw gateway, проигрывает ответ через ElevenLabs.
|
||||
|
||||
Два агента: **Cosmo** (владельца) и **Люся** (жены) — каждый со своим wake word и своим gateway.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
mic ─► wake word (openwakeword) ─► STT (Groq) ─► OpenClaw gateway ─► TTS (ElevenLabs) ─► mpv ─► speakers
|
||||
```
|
||||
|
||||
- **Wake word:** openwakeword (обучается на своих записях, см. ниже). Раньше планировался Porcupine — отказались.
|
||||
- **STT:** Groq API, `whisper-large-v3-turbo`, ru.
|
||||
- **LLM:** OpenClaw gateway на N100 (`192.168.31.103:18789` для cosmo, `:18790` для lusya), `openai/gpt-5.4-mini`.
|
||||
- **TTS:** ElevenLabs `eleven_flash_v2_5` стримом через mpv stdin.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
home-voice-assistant/
|
||||
├── satellite.py # entry-обёртка
|
||||
├── satellite/ # рантайм
|
||||
│ ├── __main__.py # python -m satellite [--wake]
|
||||
│ ├── config.py, text.py
|
||||
│ ├── stt.py, audio.py, tts.py, llm.py
|
||||
│ └── modes.py # run_with_enter / run_with_porcupine (wake word)
|
||||
├── record_wav.py # запись датасета для wake word
|
||||
├── remove_silent.py # чистка тихих + перенумерация
|
||||
├── training/ # пайплайн обучения wake word
|
||||
│ ├── step_1.py … step_5.py
|
||||
│ ├── training_config.json
|
||||
│ ├── own_samples/<word>/{positive,negative}/*.wav
|
||||
│ ├── openwakeword/ # форк
|
||||
│ └── my_custom_model/<word>/ # фичи + .onnx
|
||||
├── data/models/ # готовые .onnx wake word моделей
|
||||
└── deploy/ # setup.sh + systemd unit для Pi
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # заполнить ключи
|
||||
|
||||
python satellite.py # режим Enter (без wake word, для отладки)
|
||||
python satellite.py --wake # режим wake word (нужна обученная модель в data/models/)
|
||||
```
|
||||
|
||||
Системные зависимости:
|
||||
- Python 3.12+
|
||||
- `portaudio` — `brew install portaudio`
|
||||
- `mpv` — `brew install mpv`
|
||||
|
||||
## Обучение своего wake word
|
||||
|
||||
OpenWakeWord обучает DNN-модель на твоих записях слова. Пайплайн в `training/`:
|
||||
|
||||
| Шаг | Что делает |
|
||||
|-----|-----------|
|
||||
| `step_1.py` | Установка зависимостей (piper, openwakeword) |
|
||||
| `step_2.py` | Создаёт `training_config.json` (параметры обучения) |
|
||||
| `step_3.py` | Скачивает датасеты (audioset, fma, RIRs, ACAV features) — ~17 GB |
|
||||
| `step_4.py` | Аугментация → тренировка → экспорт `.onnx` в `data/models/` |
|
||||
| `step_5.py` | Проверка моделей и подсказки для `.env` |
|
||||
|
||||
### Запись датасета
|
||||
|
||||
```bash
|
||||
# по одной записи (Enter → 2 секунды → сохраняем)
|
||||
python record_wav.py cosmo positive
|
||||
python record_wav.py cosmo negative
|
||||
|
||||
# непрерывно N секунд → нарезаем по 2с, тишину выкидываем
|
||||
python record_wav.py cosmo negative long 300
|
||||
INPUT_DEVICE=1 python record_wav.py cosmo negative long 600
|
||||
```
|
||||
|
||||
`record_wav.py` отбраковывает тихие записи по `MIN_RMS=300` (изменить можно константой в начале файла).
|
||||
|
||||
### Чистка
|
||||
|
||||
```bash
|
||||
python remove_silent.py
|
||||
```
|
||||
|
||||
Удаляет файлы с RMS ниже порога и переименовывает оставшиеся в `001.wav … NNN.wav`.
|
||||
|
||||
### Тренировка
|
||||
|
||||
1. В `training/training_config.json` укажи `wake_word_list`, `use_own_samples: true`, параметры:
|
||||
```json
|
||||
{
|
||||
"wake_word_list": ["cosmo"],
|
||||
"use_own_samples": true,
|
||||
"false_activation_penalty": 100,
|
||||
"target_false_positives_per_hour": 3.0,
|
||||
"target_recall": 0.5,
|
||||
"number_of_training_steps": 3000,
|
||||
"layer_size": 64
|
||||
}
|
||||
```
|
||||
2. В `training/openwakeword/examples/custom_model.yml` подними `augmentation_rounds: 10` (или больше).
|
||||
3. Снеси кэш если был старый запуск:
|
||||
```bash
|
||||
rm -rf training/my_custom_model/<word> data/models/<word>.onnx
|
||||
```
|
||||
4. Запусти:
|
||||
```bash
|
||||
python training/step_4.py
|
||||
```
|
||||
5. Пропиши в `.env`: `WAKE_WORD_COSMO=data/models/cosmo.onnx`.
|
||||
|
||||
### Сколько данных нужно
|
||||
|
||||
| Positive | Negative | Recall |
|
||||
|---|---|---|
|
||||
| 100–200 | 200+ | 0.1–0.3 (плохо) |
|
||||
| 300–500 | 500+ | 0.4–0.6 (минимум) |
|
||||
| 800–1500 | 1000+ | 0.7–0.85 |
|
||||
| 2000+ | 2000+ | 0.9+ |
|
||||
|
||||
Главное — **разнообразие**: разные дистанции до микрофона, интонации, время дня, фоны. Аугментация (`augmentation_rounds`) умножит твой датасет в N раз во время обучения.
|
||||
|
||||
Негативы должны включать **фонетически близкие** слова ("космос", "косо", "просто"), обычную речь, имена других ассистентов ("алиса", "сири"), бытовые звуки.
|
||||
|
||||
## Архитектурные решения
|
||||
|
||||
- **Одна сессия диалога на день** на агента (`Conversation` в `llm.py`). История хранится клиентом, отправляется целиком. Сброс — фразой "сбрось историю" или сменой даты.
|
||||
- **Keep-alive HTTP** (`requests.Session`) — переиспользует TCP/TLS.
|
||||
- **Streaming TTS** — ElevenLabs пайпится в `mpv` через stdin, играет пока генерируется.
|
||||
- **STT без диска** — PCM → WAV в `BytesIO` → Groq.
|
||||
- **Barge-in** — `stop_speaking()` убивает mpv при новой активации.
|
||||
- **Ошибки не роняют сервис** — каждый слой ловит `Exception`, пишет в `errors.log`.
|
||||
|
||||
## .env (ключевые переменные)
|
||||
|
||||
| Переменная | Что |
|
||||
|---|---|
|
||||
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | OpenClaw gateways |
|
||||
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Авторизация |
|
||||
| `AGENT`, `LUSYA_AGENT` | `openclaw/main`, `openclaw/wife` |
|
||||
| `VOICE_MODEL` | LLM для голоса (передаётся в `x-openclaw-model`) |
|
||||
| `GROQ_API_KEY` | STT |
|
||||
| `ELEVENLABS_API_KEY`, `COSMO_TTS_VOICE`, `LUSYA_TTS_VOICE` | TTS |
|
||||
| `WAKE_WORD_COSMO`, `WAKE_WORD_LUSYA` | Пути к `.onnx` моделям |
|
||||
| `SILENCE_THRESHOLD`, `SILENCE_DURATION` | VAD |
|
||||
| `MAX_HISTORY` | Лимит сообщений в сессии |
|
||||
| `AUDIO_SINK` | На Pi: `bluez_sink.XX_XX_XX.a2dp_sink` |
|
||||
|
||||
## Деплой на Raspberry Pi
|
||||
|
||||
```bash
|
||||
sudo bash deploy/setup.sh
|
||||
sudo systemctl start cosmo-satellite
|
||||
sudo journalctl -u cosmo-satellite -f
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Модулизация satellite
|
||||
- [x] ElevenLabs streaming + barge-in
|
||||
- [x] Сессии диалога с автосбросом
|
||||
- [x] Пайплайн тренировки wake word на своих записях
|
||||
- [ ] Обучить рабочую модель cosmo (нужно ~500+ позитивов)
|
||||
- [ ] Подключить Люсю в `run_with_porcupine` (сейчас грузится только cosmo)
|
||||
- [ ] Проверить systemd autostart на Pi в проде
|
||||
- [ ] Home Assistant tool в OpenClaw
|
||||
- [ ] Real-time barge-in (прерывание голосом во время TTS)
|
||||
- [ ] Контекст окружения в system prompt
|
||||
- [ ] Speaker identification
|
||||
- [ ] Проактивные уведомления через WebSocket
|
||||
@@ -58,6 +58,15 @@ if [ -z "${GROQ_API_KEY:-}" ]; then
|
||||
echo "❌ GROQ_API_KEY не задан в .env"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${ELEVENLABS_API_KEY:-}" ]; then
|
||||
echo "❌ ELEVENLABS_API_KEY не задан в .env"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${WAKE_WORD_COSMO:-}" ] || [ ! -f "$APP_DIR/${WAKE_WORD_COSMO}" ]; then
|
||||
echo "❌ WAKE_WORD_COSMO не задан или файл .onnx не найден"
|
||||
echo " Обучи модель локально (training/step_4.py) и положи .onnx в data/models/"
|
||||
exit 1
|
||||
fi
|
||||
echo " .env OK"
|
||||
|
||||
# --- 4. Bluetooth (PulseAudio) ---
|
||||
@@ -111,6 +120,7 @@ echo " cat ${APP_DIR}/errors.log # лог ошибок"
|
||||
echo ""
|
||||
echo "Не забудь:"
|
||||
echo " 1. Подключить BT колонку и прописать AUDIO_SINK в .env"
|
||||
echo " 2. Прописать PORCUPINE_KEY и WAKE_WORD_MODEL в .env"
|
||||
echo " 2. Положить обученную модель в data/models/cosmo.onnx и прописать"
|
||||
echo " WAKE_WORD_COSMO=data/models/cosmo.onnx в .env"
|
||||
echo " 3. Затем: sudo systemctl start ${SERVICE_NAME}"
|
||||
echo ""
|
||||
|
||||
105
record_wav.py
105
record_wav.py
@@ -1,48 +1,97 @@
|
||||
import sounddevice as sd
|
||||
import scipy.io.wavfile as wav
|
||||
import numpy as np
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 1. Проверка аргументов командной строки
|
||||
MIN_RMS = 300 # ниже = почти тишина, не сохраняем
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK_DURATION = 2 # сек на один wav
|
||||
INPUT_DEVICE = os.getenv("INPUT_DEVICE") # имя или индекс устройства, иначе системный default
|
||||
if INPUT_DEVICE and INPUT_DEVICE.isdigit():
|
||||
INPUT_DEVICE = int(INPUT_DEVICE)
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("Использование: python record.py <имя_модели> <positive/negative>")
|
||||
print("Пример: python record.py cosmo positive")
|
||||
print("Использование: python record_wav.py <имя_модели> <positive/negative> [long [секунд]]")
|
||||
print("Примеры:")
|
||||
print(" python record_wav.py cosmo positive # по 2с с Enter")
|
||||
print(" python record_wav.py cosmo negative long # 5 минут подряд → нарезать")
|
||||
print(" python record_wav.py cosmo negative long 600 # 10 минут подряд")
|
||||
sys.exit(1)
|
||||
|
||||
MODEL_NAME = sys.argv[1]
|
||||
MODE = sys.argv[2]
|
||||
BASE_DIR = os.path.join("data", "wakewords", MODEL_NAME, MODE)
|
||||
LONG_MODE = len(sys.argv) > 3 and sys.argv[3] == "long"
|
||||
LONG_DURATION = int(sys.argv[4]) if LONG_MODE and len(sys.argv) > 4 else 300
|
||||
|
||||
# Создаем папку, если ее нет
|
||||
if not os.path.exists(BASE_DIR):
|
||||
os.makedirs(BASE_DIR)
|
||||
BASE_DIR = os.path.join("training", "own_samples", MODEL_NAME, MODE)
|
||||
os.makedirs(BASE_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def next_index() -> int:
|
||||
files = [f for f in os.listdir(BASE_DIR) if f.endswith('.wav')]
|
||||
return len(files) + 1
|
||||
|
||||
def get_next_filename(directory):
|
||||
files = [f for f in os.listdir(directory) if f.endswith('.wav')]
|
||||
return f"{len(files) + 1:03d}.wav"
|
||||
|
||||
def record_sample():
|
||||
filename = get_next_filename(BASE_DIR)
|
||||
idx = next_index()
|
||||
filename = f"{idx:03d}.wav"
|
||||
filepath = os.path.join(BASE_DIR, filename)
|
||||
|
||||
sample_rate = 16000
|
||||
duration = 2
|
||||
|
||||
|
||||
print(f"\n[!] Файл {filename} готов к записи.")
|
||||
input("Нажмите Enter, чтобы начать запись (2 секунды)...")
|
||||
|
||||
input(f"Нажмите Enter, чтобы начать запись ({CHUNK_DURATION} секунды)...")
|
||||
|
||||
print("Запись...")
|
||||
recording = sd.rec(int(duration * sample_rate), samplerate=sample_rate, channels=1)
|
||||
recording = sd.rec(device=INPUT_DEVICE, frames=int(CHUNK_DURATION * SAMPLE_RATE), samplerate=SAMPLE_RATE, channels=1, dtype='int16')
|
||||
sd.wait()
|
||||
wav.write(filepath, sample_rate, recording)
|
||||
print(f"Сохранено в: {filepath}")
|
||||
rms = float(np.sqrt(np.mean(recording.astype(np.float64) ** 2)))
|
||||
if rms < MIN_RMS:
|
||||
print(f"⚠️ Тишина (RMS={rms:.0f} < {MIN_RMS}) — не сохраняю, повтори")
|
||||
return
|
||||
wav.write(filepath, SAMPLE_RATE, recording)
|
||||
print(f"Сохранено: {filepath} (RMS={rms:.0f})")
|
||||
|
||||
# 2. Основной цикл записи
|
||||
print(f"--- Режим записи: {MODEL_NAME} / {MODE} ---")
|
||||
print("Для выхода нажмите Ctrl+C")
|
||||
|
||||
try:
|
||||
while True:
|
||||
record_sample()
|
||||
except KeyboardInterrupt:
|
||||
print("\nЗапись завершена.")
|
||||
def record_long(total_seconds: int):
|
||||
"""Запись N секунд непрерывно, потом нарезка на CHUNK_DURATION-секундные wav."""
|
||||
print(f"\n[!] Запись {total_seconds}с одним куском, потом нарежу по {CHUNK_DURATION}с.")
|
||||
input("Нажмите Enter, чтобы начать (Ctrl+C прервёт сохранение)...")
|
||||
|
||||
print(f"🎙️ Запись... ({total_seconds}с)")
|
||||
recording = sd.rec(device=INPUT_DEVICE, frames=int(total_seconds * SAMPLE_RATE), samplerate=SAMPLE_RATE, channels=1, dtype='int16')
|
||||
try:
|
||||
sd.wait()
|
||||
except KeyboardInterrupt:
|
||||
sd.stop()
|
||||
print("\n⏹️ Прервано — сохраняю записанное")
|
||||
|
||||
audio = recording.flatten()
|
||||
chunk_samples = CHUNK_DURATION * SAMPLE_RATE
|
||||
n_chunks = len(audio) // chunk_samples
|
||||
saved = skipped = 0
|
||||
start_idx = next_index()
|
||||
|
||||
for i in range(n_chunks):
|
||||
chunk = audio[i * chunk_samples:(i + 1) * chunk_samples]
|
||||
rms = float(np.sqrt(np.mean(chunk.astype(np.float64) ** 2)))
|
||||
if rms < MIN_RMS:
|
||||
skipped += 1
|
||||
continue
|
||||
filename = f"{start_idx + saved:03d}.wav"
|
||||
wav.write(os.path.join(BASE_DIR, filename), SAMPLE_RATE, chunk)
|
||||
saved += 1
|
||||
|
||||
print(f"\n✅ Нарезано {n_chunks} кусков → сохранено {saved}, пропущено тихих {skipped}")
|
||||
|
||||
|
||||
print(f"--- Режим записи: {MODEL_NAME} / {MODE}{' / LONG' if LONG_MODE else ''} ---")
|
||||
|
||||
if LONG_MODE:
|
||||
record_long(LONG_DURATION)
|
||||
else:
|
||||
print("Для выхода нажмите Ctrl+C")
|
||||
try:
|
||||
while True:
|
||||
record_sample()
|
||||
except KeyboardInterrupt:
|
||||
print("\nЗапись завершена.")
|
||||
|
||||
20
remove_silent.py
Normal file
20
remove_silent.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import wave
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
for sub, t in [('positive', 250), ('negative', 200)]:
|
||||
d = Path(f'training/own_samples/cosmo/{sub}')
|
||||
removed = 0
|
||||
for f in sorted(d.glob('*.wav')):
|
||||
with wave.open(str(f)) as w:
|
||||
data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
|
||||
if np.sqrt(np.mean(data.astype(np.float64)**2)) < t:
|
||||
f.unlink(); removed += 1
|
||||
|
||||
files = sorted(d.glob('*.wav'))
|
||||
for i, f in enumerate(files, 1):
|
||||
f.rename(d / f'_tmp_{i:03d}.wav')
|
||||
for i, f in enumerate(sorted(d.glob('_tmp_*.wav')), 1):
|
||||
f.rename(d / f'{i:03d}.wav')
|
||||
|
||||
print(f'{sub}: removed {removed}, renumbered → 001..{len(files):03d}.wav')
|
||||
@@ -1,9 +1,17 @@
|
||||
faster-whisper
|
||||
pyaudio
|
||||
requests
|
||||
python-dotenv
|
||||
numpy
|
||||
|
||||
# Audio I/O
|
||||
pyaudio
|
||||
sounddevice
|
||||
scipy
|
||||
|
||||
# STT через облако
|
||||
groq
|
||||
|
||||
# TTS
|
||||
elevenlabs
|
||||
# Раскомментировать когда будет Pi + Porcupine:
|
||||
# pvporcupine
|
||||
|
||||
# Wake word
|
||||
openwakeword
|
||||
|
||||
@@ -63,11 +63,6 @@ AGENTS = {
|
||||
},
|
||||
}
|
||||
|
||||
# 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", "")
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
from datetime import date
|
||||
|
||||
from .config import GATEWAY_URL, VOICE_MODEL, AGENT, AGENTS, log
|
||||
from .config import AGENTS, log
|
||||
from .text import clean_for_speech, find_sentence_end
|
||||
from .tts import speak, play_error_sound
|
||||
|
||||
SYSTEM_PROMPT = "Отвечай кратко, 1-2 предложения, без markdown, без эмодзи."
|
||||
MAX_HISTORY = int(__import__("os").getenv("MAX_HISTORY", "20"))
|
||||
MAX_HISTORY = int(os.getenv("MAX_HISTORY", "20"))
|
||||
|
||||
RESET_PATTERNS = re.compile(
|
||||
r"(начни|начать|создай|открой|давай).{0,10}(новую|новый|чистую|чистый).{0,10}(сессию|сессия|диалог|разговор|чат)"
|
||||
|
||||
@@ -75,83 +75,67 @@ def run_with_enter():
|
||||
|
||||
|
||||
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 numpy as np
|
||||
import pyaudio
|
||||
from openwakeword.model import Model
|
||||
|
||||
porcupine = pvporcupine.create(
|
||||
access_key=porcupine_key,
|
||||
keyword_paths=keyword_paths,
|
||||
cosmo_model = Model(
|
||||
wakeword_models=[os.getenv("WAKE_WORD_COSMO")],
|
||||
inference_framework="onnx",
|
||||
)
|
||||
# TODO: подключить Люсю — раскомментировать когда модель lusya обучена
|
||||
# lusya_model = Model(
|
||||
# wakeword_models=[os.getenv("WAKE_WORD_LUSYA")],
|
||||
# inference_framework="onnx",
|
||||
# )
|
||||
|
||||
audio = pyaudio.PyAudio()
|
||||
stream = audio.open(
|
||||
rate=porcupine.sample_rate,
|
||||
channels=1,
|
||||
format=pyaudio.paInt16,
|
||||
input=True,
|
||||
frames_per_buffer=porcupine.frame_length,
|
||||
)
|
||||
# OpenWakeWord ожидает 16 kHz mono PCM 16-bit, фреймы по 1280 семплов (80 мс)
|
||||
stream = audio.open(rate=16000, channels=1, format=pyaudio.paInt16,
|
||||
input=True, frames_per_buffer=1280)
|
||||
|
||||
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")
|
||||
print("✅ Слушаю через OpenWakeWord...")
|
||||
print("\nСкажи 'Космо'...\n")
|
||||
# print("\nСкажи 'Космо' или 'Люся'...\n") # TODO: после подключения Люси
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
pcm = stream.read(porcupine.frame_length)
|
||||
pcm = struct.unpack_from("h" * porcupine.frame_length, pcm)
|
||||
pcm = stream.read(1280, exception_on_overflow=False)
|
||||
pcm = np.frombuffer(pcm, dtype=np.int16)
|
||||
|
||||
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}'!")
|
||||
cosmo_score = cosmo_model.predict(pcm)["cosmo"]
|
||||
if cosmo_score > 0.1:
|
||||
print(f"PREDICTION cosmo: {cosmo_score:.3f}")
|
||||
|
||||
# отпускаем микрофон на время диалога
|
||||
if cosmo_score > 0.5:
|
||||
print("✅ Услышал 'Космо'!")
|
||||
stream.stop_stream()
|
||||
_conversation_loop(agent_id, agent_name)
|
||||
_conversation_loop("cosmo", "Cosmo")
|
||||
cosmo_model.reset()
|
||||
stream.start_stream()
|
||||
continue
|
||||
|
||||
# TODO: Люся — раскомментировать когда модель готова
|
||||
# lusya_score = lusya_model.predict(pcm)["lusya"]
|
||||
# if lusya_score > 0.1:
|
||||
# print(f"PREDICTION lusya: {lusya_score:.3f}")
|
||||
# if lusya_score > 0.5:
|
||||
# print("✅ Услышала 'Люся'!")
|
||||
# stream.stop_stream()
|
||||
# _conversation_loop("lusya", "Люся")
|
||||
# lusya_model.reset()
|
||||
# stream.start_stream()
|
||||
# continue
|
||||
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.exception("Непредвиденная ошибка в цикле Porcupine")
|
||||
log.exception("Непредвиденная ошибка в wake-word цикле")
|
||||
print(f"⚠️ Ошибка: {e} — продолжаю слушать...\n")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Выход")
|
||||
finally:
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
audio.terminate()
|
||||
porcupine.delete()
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
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
|
||||
from .config import groq_client, log
|
||||
|
||||
|
||||
def frames_to_wav(frames: list[bytes]) -> bytes:
|
||||
"""Конвертирует сырые PCM фреймы в WAV в памяти"""
|
||||
"""Сырые PCM-фреймы → WAV в памяти (без диска)."""
|
||||
buf = io.BytesIO()
|
||||
wf = wave.open(buf, "wb")
|
||||
wf.setnchannels(1)
|
||||
@@ -29,26 +17,17 @@ def frames_to_wav(frames: list[bytes]) -> bytes:
|
||||
|
||||
|
||||
def transcribe(frames: list[bytes]) -> str:
|
||||
"""Транскрибирует аудио фреймы — всё в памяти, без диска"""
|
||||
"""STT через Groq whisper-large-v3-turbo. Всё в памяти."""
|
||||
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)
|
||||
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
|
||||
except Exception as e:
|
||||
log.exception("STT ошибка")
|
||||
print(f"⚠️ Ошибка распознавания речи: {e}")
|
||||
|
||||
@@ -133,9 +133,6 @@ def play_activation_sound():
|
||||
|
||||
def play_error_sound():
|
||||
"""Звук ошибки — 'не получилось'"""
|
||||
import traceback
|
||||
print("🔴 play_error_sound вызван из:")
|
||||
traceback.print_stack()
|
||||
try:
|
||||
_play_sound_file("Error_Cosmo.mp3")
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user