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:
2026-04-13 15:40:44 +03:00
parent 0a89bf5105
commit 780f6f0084
13 changed files with 378 additions and 140 deletions

View File

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

@@ -33,3 +33,4 @@ data/
# Training
training/
training/openwakeword

View File

@@ -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
View 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 |
|---|---|---|
| 100200 | 200+ | 0.10.3 (плохо) |
| 300500 | 500+ | 0.40.6 (минимум) |
| 8001500 | 1000+ | 0.70.85 |
| 2000+ | 2000+ | 0.9+ |
Главное — **разнообразие**: разные дистанции до микрофона, интонации, время дня, фоны. Аугментация (`augmentation_rounds`) умножит твой датасет в N раз во время обучения.
Негативы должны включать **фонетически близкие** слова ("космос", "косо", "просто"), обычную речь, имена других ассистентов ("алиса", "сири"), бытовые звуки.
## Архитектурные решения
- **Одна сессия диалога на день** на агента (`Conversation` в `llm.py`). История хранится клиентом, отправляется целиком. Сброс — фразой "сбрось историю" или сменой даты.
- **Keep-alive HTTP** (`requests.Session`) — переиспользует TCP/TLS.
- **Streaming TTS** — ElevenLabs пайпится в `mpv` через stdin, играет пока генерируется.
- **STT без диска** — PCM → WAV в `BytesIO` → Groq.
- **Barge-in** — `stop_speaking()` убивает mpv при новой активации.
- **Ошибки не роняют сервис** — каждый слой ловит `Exception`, пишет в `errors.log`.
## .env (ключевые переменные)
| Переменная | Что |
|---|---|
| `GATEWAY_URL`, `LUSYA_GATEWAY_URL` | OpenClaw gateways |
| `GATEWAY_TOKEN`, `LUSYA_GATEWAY_TOKEN` | Авторизация |
| `AGENT`, `LUSYA_AGENT` | `openclaw/main`, `openclaw/wife` |
| `VOICE_MODEL` | LLM для голоса (передаётся в `x-openclaw-model`) |
| `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

View File

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

View File

@@ -1,46 +1,95 @@
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} ---")
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()

20
remove_silent.py Normal file
View 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')

View File

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

View File

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

View File

@@ -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}(сессию|сессия|диалог|разговор|чат)"

View File

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

View File

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

View File

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