diff --git a/.env.example b/.env.example index 9055d42..4d02a56 100644 --- a/.env.example +++ b/.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= diff --git a/.gitignore b/.gitignore index 63d7db7..b54711c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ data/ # Training training/ +training/openwakeword diff --git a/CLAUDE.md b/CLAUDE.md index 12da86d..f3f763f 100644 --- a/CLAUDE.md +++ b/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 ` — запись 16kHz mono PCM 16-bit в `training/own_samples//` +- `training/step_1.py` … `step_5.py` — установка зависимостей, конвертация датасетов, генерация конфига, обучение, экспорт в `data/models/.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 (прерывание по голосу во время озвучки, не только по новой активации) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1168751 --- /dev/null +++ b/README.md @@ -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//{positive,negative}/*.wav +│ ├── openwakeword/ # форк +│ └── my_custom_model// # фичи + .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/ data/models/.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 diff --git a/deploy/setup.sh b/deploy/setup.sh index b389d6c..eba6df9 100755 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -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 "" diff --git a/record_wav.py b/record_wav.py index e955079..887eb51 100644 --- a/record_wav.py +++ b/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 <имя_модели> ") - print("Пример: python record.py cosmo positive") + print("Использование: python record_wav.py <имя_модели> [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Запись завершена.") \ No newline at end of file +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Запись завершена.") diff --git a/remove_silent.py b/remove_silent.py new file mode 100644 index 0000000..feac9de --- /dev/null +++ b/remove_silent.py @@ -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') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cf08e34..a510367 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,17 @@ -faster-whisper -pyaudio requests python-dotenv numpy + +# Audio I/O +pyaudio +sounddevice +scipy + +# STT через облако groq + +# TTS elevenlabs -# Раскомментировать когда будет Pi + Porcupine: -# pvporcupine \ No newline at end of file + +# Wake word +openwakeword diff --git a/satellite/config.py b/satellite/config.py index 27f8a6a..612250b 100644 --- a/satellite/config.py +++ b/satellite/config.py @@ -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", "") diff --git a/satellite/llm.py b/satellite/llm.py index 7d10e40..4646f5f 100644 --- a/satellite/llm.py +++ b/satellite/llm.py @@ -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}(сессию|сессия|диалог|разговор|чат)" diff --git a/satellite/modes.py b/satellite/modes.py index 56c757d..a1aa6bc 100644 --- a/satellite/modes.py +++ b/satellite/modes.py @@ -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() diff --git a/satellite/stt.py b/satellite/stt.py index a3909b6..728fd49 100644 --- a/satellite/stt.py +++ b/satellite/stt.py @@ -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}") diff --git a/satellite/tts.py b/satellite/tts.py index 599a1dd..23c40e7 100644 --- a/satellite/tts.py +++ b/satellite/tts.py @@ -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: