другая структура проекта + beads + александр повтори + комментарии везде + readme
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -37,3 +37,7 @@ vosk-model-*/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
|
||||
.beads
|
||||
.gitattributes
|
||||
|
||||
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Agent Instructions
|
||||
|
||||
This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --status in_progress # Claim work
|
||||
bd close <id> # Complete work
|
||||
bd sync # Sync with git
|
||||
```
|
||||
|
||||
## Landing the Plane (Session Completion)
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd sync
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Умная колонка «Alexandr»
|
||||
|
||||
Небольшой голосовой ассистент для Linux: реагирует на wake word, распознает речь, обращается к AI, озвучивает ответы, умеет переводить и работать с будильником.
|
||||
|
||||
## Возможности
|
||||
- Wake word: «Alexandr» (Porcupine).
|
||||
- Распознавание речи (Deepgram, RU/EN).
|
||||
- Озвучка (Silero TTS, RU/EN).
|
||||
- Перевод RU↔EN (Perplexity).
|
||||
- Будильник с локальным распознаванием стоп-команд (Vosk).
|
||||
- Управление громкостью (ALSA amixer).
|
||||
|
||||
## Требования
|
||||
- Linux
|
||||
- Python 3.9+
|
||||
- Системные утилиты: `mpg123`, `amixer` (ALSA)
|
||||
- Драйверы/библиотеки для микрофона (PortAudio)
|
||||
|
||||
## Установка
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Настройка окружения
|
||||
Создайте `.env` в корне проекта:
|
||||
```
|
||||
PERPLEXITY_API_KEY=...
|
||||
PERPLEXITY_MODEL=llama-3.1-sonar-small-128k-chat
|
||||
DEEPGRAM_API_KEY=...
|
||||
PORCUPINE_ACCESS_KEY=...
|
||||
TTS_EN_SPEAKER=en_0
|
||||
```
|
||||
|
||||
## Запуск
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Примеры команд
|
||||
- Активировать: «Alexandr»
|
||||
- Перевод: «переведи на английский как пройти в библиотеку»
|
||||
- Перевод: «переведи с английского» → сказать фразу на английском
|
||||
- Громкость: «громкость 5»
|
||||
- Будильник: «будильник на 7:30», «разбуди на 8 15»
|
||||
- Отмена будильников: «отмени будильник»
|
||||
- Стоп/сброс: «стоп», «хватит»
|
||||
|
||||
## Объяснение работы
|
||||
1) Система ждет wake word («Alexandr») через Porcupine.
|
||||
2) После активации включается распознавание речи (Deepgram).
|
||||
3) Команда распознается и проверяется на спец‑действия: будильник, громкость, перевод.
|
||||
4) Если это перевод — отправляется отдельный запрос в Perplexity и результат озвучивается.
|
||||
5) Если это обычный запрос — идет в AI, ответ очищается от разметки и озвучивается.
|
||||
6) После ответа включается режим продолжения диалога без повторного wake word.
|
||||
|
||||
## Архитектурная схема
|
||||
```
|
||||
Микрофон
|
||||
│
|
||||
▼
|
||||
Wake word (Porcupine) ──► STT (Deepgram) ──► Логика команд
|
||||
│
|
||||
├─► Будильник (alarm.py)
|
||||
├─► Громкость (sound_level.py)
|
||||
├─► Перевод (ai.py)
|
||||
└─► Диалог (ai.py)
|
||||
│
|
||||
▼
|
||||
Очистка текста (cleaner.py)
|
||||
│
|
||||
▼
|
||||
TTS (tts.py)
|
||||
│
|
||||
▼
|
||||
Динамик
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
- `main.py` — основной цикл работы ассистента.
|
||||
- `wakeword.py` — детектор wake word (Porcupine).
|
||||
- `stt.py` — потоковое распознавание речи (Deepgram).
|
||||
- `tts.py` — озвучивание (Silero TTS).
|
||||
- `ai.py` — запросы к Perplexity (чат и перевод).
|
||||
- `cleaner.py` — очистка ответа и преобразование чисел (RU).
|
||||
- `alarm.py` — будильник и логика расписания.
|
||||
- `local_stt.py` — локальный Vosk для стоп-команд.
|
||||
- `sound_level.py` — управление громкостью.
|
||||
|
||||
## Частые проблемы
|
||||
- Ошибка Deepgram 400: проверьте `DEEPGRAM_API_KEY` и доступность модели.
|
||||
- Нет звука: проверьте `amixer` и настройки ALSA.
|
||||
- Будильник не играет: установите `mpg123`.
|
||||
|
||||
## Лицензия
|
||||
См. `LICENSE.txt`.
|
||||
12
alarms.json
12
alarms.json
@@ -1,12 +0,0 @@
|
||||
[
|
||||
{
|
||||
"hour": 10,
|
||||
"minute": 15,
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"hour": 3,
|
||||
"minute": 42,
|
||||
"active": false
|
||||
}
|
||||
]
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/audio/__init__.py
Normal file
0
app/audio/__init__.py
Normal file
@@ -2,14 +2,23 @@
|
||||
Local offline Speech-to-Text module using Vosk.
|
||||
Used for simple command detection (like "stop") without internet.
|
||||
"""
|
||||
|
||||
# Модуль локального распознавания речи (Vosk).
|
||||
# Работает полностью оффлайн (без интернета).
|
||||
# Используется, когда нужно распознать простые команды (например, "стоп" во время будильника),
|
||||
# чтобы не тратить трафик и время на обращение к облаку.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pyaudio
|
||||
from vosk import Model, KaldiRecognizer
|
||||
from config import VOSK_MODEL_PATH, SAMPLE_RATE
|
||||
from ..core.config import VOSK_MODEL_PATH, SAMPLE_RATE
|
||||
|
||||
|
||||
class LocalRecognizer:
|
||||
"""Класс для работы с Vosk."""
|
||||
|
||||
def __init__(self):
|
||||
self.model = None
|
||||
self.rec = None
|
||||
@@ -17,22 +26,25 @@ class LocalRecognizer:
|
||||
self.stream = None
|
||||
|
||||
def initialize(self):
|
||||
"""Загрузка модели Vosk."""
|
||||
if not os.path.exists(VOSK_MODEL_PATH):
|
||||
print(f"❌ Ошибка: Vosk модель не найдена по пути {VOSK_MODEL_PATH}")
|
||||
return False
|
||||
|
||||
|
||||
print("📦 Инициализация локального STT (Vosk)...")
|
||||
# Redirect stderr to suppress Vosk logs
|
||||
|
||||
# Трюк для подавления вывода логов Vosk в консоль (он очень шумный)
|
||||
try:
|
||||
null_fd = os.open(os.devnull, os.O_WRONLY)
|
||||
old_stderr = os.dup(2)
|
||||
sys.stderr.flush()
|
||||
os.dup2(null_fd, 2)
|
||||
os.close(null_fd)
|
||||
|
||||
|
||||
# Сама загрузка модели
|
||||
self.model = Model(str(VOSK_MODEL_PATH))
|
||||
|
||||
# Restore stderr
|
||||
|
||||
# Возвращаем stderr обратно
|
||||
os.dup2(old_stderr, 2)
|
||||
os.close(old_stderr)
|
||||
except Exception as e:
|
||||
@@ -45,72 +57,91 @@ class LocalRecognizer:
|
||||
|
||||
def listen_for_keywords(self, keywords: list, timeout: float = 10.0) -> str:
|
||||
"""
|
||||
Listen for specific keywords locally.
|
||||
Returns the recognized keyword if found, or empty string.
|
||||
Слушает микрофон заданное время и проверяет наличие ключевых слов.
|
||||
|
||||
Args:
|
||||
keywords: Список слов, которые мы ждем (например, ["стоп", "хватит"]).
|
||||
timeout: Сколько секунд слушать.
|
||||
|
||||
Returns:
|
||||
Найденное слово или пустую строку.
|
||||
"""
|
||||
if not self.model:
|
||||
if not self.initialize():
|
||||
return ""
|
||||
|
||||
# Open stream
|
||||
# Открываем поток микрофона
|
||||
try:
|
||||
stream = self.pa.open(format=pyaudio.paInt16, channels=1, rate=SAMPLE_RATE, input=True, frames_per_buffer=4096)
|
||||
stream = self.pa.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=1,
|
||||
rate=SAMPLE_RATE,
|
||||
input=True,
|
||||
frames_per_buffer=4096,
|
||||
)
|
||||
stream.start_stream()
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка микрофона: {e}")
|
||||
return ""
|
||||
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
print(f"👂 Локальное слушание ожидает: {keywords}")
|
||||
|
||||
|
||||
detected_text = ""
|
||||
|
||||
|
||||
try:
|
||||
while time.time() - start_time < timeout:
|
||||
data = stream.read(4096, exception_on_overflow=False)
|
||||
|
||||
# Vosk обрабатывает аудио чанками
|
||||
if self.rec.AcceptWaveform(data):
|
||||
# Полный результат
|
||||
res = json.loads(self.rec.Result())
|
||||
text = res.get("text", "")
|
||||
if text:
|
||||
print(f"📝 Локально: {text}")
|
||||
# Check against keywords
|
||||
# Проверяем, есть ли ключевое слово в распознанном тексте
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
detected_text = text
|
||||
break
|
||||
else:
|
||||
# Partial result
|
||||
# Частичный результат (быстрее, чем полный)
|
||||
res = json.loads(self.rec.PartialResult())
|
||||
partial = res.get("partial", "")
|
||||
if partial:
|
||||
for kw in keywords:
|
||||
for kw in keywords:
|
||||
if kw in partial:
|
||||
detected_text = partial
|
||||
break
|
||||
|
||||
|
||||
if detected_text:
|
||||
break
|
||||
finally:
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
|
||||
|
||||
return detected_text
|
||||
|
||||
def cleanup(self):
|
||||
if self.pa:
|
||||
self.pa.terminate()
|
||||
|
||||
# Global instance
|
||||
|
||||
# Глобальный экземпляр
|
||||
_local_recognizer = None
|
||||
|
||||
|
||||
def get_local_recognizer():
|
||||
global _local_recognizer
|
||||
if _local_recognizer is None:
|
||||
_local_recognizer = LocalRecognizer()
|
||||
return _local_recognizer
|
||||
|
||||
|
||||
def listen_for_keywords(keywords: list, timeout: float = 5.0) -> str:
|
||||
"""Listen for keywords using Vosk."""
|
||||
"""Внешняя функция для поиска ключевых слов."""
|
||||
return get_local_recognizer().listen_for_keywords(keywords, timeout)
|
||||
87
app/audio/sound_level.py
Normal file
87
app/audio/sound_level.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Volume control module.
|
||||
Regulates system volume on a scale from 1 to 10.
|
||||
"""
|
||||
|
||||
# Модуль управления громкостью системы.
|
||||
# Работает через системную утилиту amixer (ALSA) в Linux.
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
# Карта для перевода слов в цифры ("пять" -> 5)
|
||||
NUMBER_MAP = {
|
||||
"один": 1,
|
||||
"раз": 1,
|
||||
"два": 2,
|
||||
"три": 3,
|
||||
"четыре": 4,
|
||||
"пять": 5,
|
||||
"шесть": 6,
|
||||
"семь": 7,
|
||||
"восемь": 8,
|
||||
"девять": 9,
|
||||
"десять": 10,
|
||||
}
|
||||
|
||||
|
||||
def set_volume(level: int) -> bool:
|
||||
"""
|
||||
Устанавливает системную громкость (шкала 1-10).
|
||||
1 -> 10%
|
||||
10 -> 100%
|
||||
|
||||
Args:
|
||||
level: Число от 1 до 10.
|
||||
|
||||
Returns:
|
||||
True, если успешно.
|
||||
"""
|
||||
if not isinstance(level, int):
|
||||
print(
|
||||
f"❌ Ошибка: Уровень громкости должен быть целым числом, получено {type(level)}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Ограничение диапазона
|
||||
if level < 1:
|
||||
level = 1
|
||||
elif level > 10:
|
||||
level = 10
|
||||
|
||||
percentage = level * 10
|
||||
|
||||
try:
|
||||
# Вызов команды amixer для изменения громкости Master канала
|
||||
# -q: quiet (без вывода)
|
||||
# sset: simple set
|
||||
cmd = ["amixer", "-q", "sset", "Master", f"{percentage}%"]
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"🔊 Громкость установлена на {level} ({percentage}%)")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ Ошибка при установке громкости: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Неизвестная ошибка громкости: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def parse_volume_text(text: str) -> int | None:
|
||||
"""
|
||||
Пытается найти число громкости в тексте.
|
||||
Понимает и цифры ("5"), и слова ("пять").
|
||||
"""
|
||||
text = text.lower()
|
||||
|
||||
# 1. Ищем цифры (1-10)
|
||||
num_match = re.search(r"\b(10|[1-9])\b", text)
|
||||
if num_match:
|
||||
return int(num_match.group())
|
||||
|
||||
# 2. Ищем слова из словаря
|
||||
for word, value in NUMBER_MAP.items():
|
||||
if word in text:
|
||||
return value
|
||||
|
||||
return None
|
||||
284
app/audio/stt.py
Normal file
284
app/audio/stt.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Speech-to-Text module using Deepgram API.
|
||||
Recognizes speech from microphone using streaming WebSocket.
|
||||
Supports Russian (default) and English.
|
||||
"""
|
||||
|
||||
# Модуль распознавания речи (STT - Speech-to-Text).
|
||||
# Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени.
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import pyaudio
|
||||
import logging
|
||||
from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE
|
||||
from deepgram import (
|
||||
DeepgramClient,
|
||||
DeepgramClientOptions,
|
||||
LiveTranscriptionEvents,
|
||||
LiveOptions,
|
||||
)
|
||||
import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws
|
||||
import websockets.sync.client
|
||||
|
||||
# --- Патч (исправление) для библиотеки websockets ---
|
||||
# По умолчанию Deepgram SDK использует слишком короткий таймаут подключения.
|
||||
# Это часто вызывает ошибки при медленном SSL рукопожатии.
|
||||
# Мы подменяем функцию connect, чтобы увеличить таймаут до 30 секунд.
|
||||
_original_connect = websockets.sync.client.connect
|
||||
|
||||
|
||||
def _patched_connect(*args, **kwargs):
|
||||
kwargs.setdefault("open_timeout", 30)
|
||||
kwargs.setdefault("ping_timeout", 30)
|
||||
kwargs.setdefault("close_timeout", 30)
|
||||
print(f"DEBUG: Connecting to Deepgram with timeout={kwargs.get('open_timeout')}s")
|
||||
return _original_connect(*args, **kwargs)
|
||||
|
||||
|
||||
# Применяем патч
|
||||
sdk_ws.connect = _patched_connect
|
||||
|
||||
# Отключаем лишний мусор в логах
|
||||
logging.getLogger("deepgram").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class SpeechRecognizer:
|
||||
"""Класс распознавания речи через Deepgram."""
|
||||
|
||||
def __init__(self):
|
||||
self.dg_client = None
|
||||
self.pa = None
|
||||
self.stream = None
|
||||
self.transcript = ""
|
||||
|
||||
def initialize(self):
|
||||
"""Инициализация клиента Deepgram и PyAudio."""
|
||||
if not DEEPGRAM_API_KEY:
|
||||
raise ValueError("DEEPGRAM_API_KEY is not set in environment or config.")
|
||||
|
||||
print("📦 Инициализация Deepgram STT...")
|
||||
config = DeepgramClientOptions(
|
||||
verbose=logging.WARNING,
|
||||
)
|
||||
self.dg_client = DeepgramClient(DEEPGRAM_API_KEY, config)
|
||||
|
||||
self.pa = pyaudio.PyAudio()
|
||||
print("✅ Deepgram клиент готов")
|
||||
|
||||
def _get_stream(self):
|
||||
"""Открывает аудиопоток PyAudio, если он еще не открыт."""
|
||||
if self.stream is None:
|
||||
self.stream = self.pa.open(
|
||||
rate=SAMPLE_RATE,
|
||||
channels=1,
|
||||
format=pyaudio.paInt16,
|
||||
input=True,
|
||||
frames_per_buffer=4096,
|
||||
)
|
||||
return self.stream
|
||||
|
||||
async def _process_audio(self, dg_connection, timeout_seconds, detection_timeout):
|
||||
"""
|
||||
Асинхронная функция для отправки аудио и получения текста.
|
||||
|
||||
Args:
|
||||
dg_connection: Активное соединение с Deepgram.
|
||||
timeout_seconds: Общее время прослушивания.
|
||||
detection_timeout: Время ожидания начала речи.
|
||||
"""
|
||||
self.transcript = ""
|
||||
transcript_parts = []
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
stream = self._get_stream()
|
||||
|
||||
# События для синхронизации
|
||||
stop_event = asyncio.Event() # Пора останавливаться
|
||||
speech_started_event = asyncio.Event() # Речь обнаружена (VAD)
|
||||
|
||||
# --- Обработчики событий Deepgram ---
|
||||
def on_transcript(unused_self, result, **kwargs):
|
||||
"""Вызывается, когда приходит часть текста."""
|
||||
sentence = result.channel.alternatives[0].transcript
|
||||
if len(sentence) == 0:
|
||||
return
|
||||
if result.is_final:
|
||||
# Собираем только финальные (подтвержденные) фразы
|
||||
transcript_parts.append(sentence)
|
||||
self.transcript = " ".join(transcript_parts).strip()
|
||||
|
||||
def on_speech_started(unused_self, speech_started, **kwargs):
|
||||
"""Вызывается, когда VAD (Voice Activity Detection) слышит голос."""
|
||||
loop.call_soon_threadsafe(speech_started_event.set)
|
||||
|
||||
def on_utterance_end(unused_self, utterance_end, **kwargs):
|
||||
"""Вызывается, когда Deepgram решает, что фраза закончилась (пауза)."""
|
||||
loop.call_soon_threadsafe(stop_event.set)
|
||||
|
||||
def on_error(unused_self, error, **kwargs):
|
||||
print(f"Error: {error}")
|
||||
loop.call_soon_threadsafe(stop_event.set)
|
||||
|
||||
# Подписываемся на события
|
||||
dg_connection.on(LiveTranscriptionEvents.Transcript, on_transcript)
|
||||
dg_connection.on(LiveTranscriptionEvents.SpeechStarted, on_speech_started)
|
||||
dg_connection.on(LiveTranscriptionEvents.UtteranceEnd, on_utterance_end)
|
||||
dg_connection.on(LiveTranscriptionEvents.Error, on_error)
|
||||
|
||||
# Параметры распознавания
|
||||
options = LiveOptions(
|
||||
model="nova-2", # Самая быстрая и точная модель
|
||||
language=self.current_lang,
|
||||
smart_format=True, # Расстановка знаков препинания
|
||||
encoding="linear16",
|
||||
channels=1,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
interim_results=True,
|
||||
utterance_end_ms=1200, # Пауза 1.2с считается концом фразы
|
||||
vad_events=True,
|
||||
)
|
||||
|
||||
if dg_connection.start(options) is False:
|
||||
print("Failed to start Deepgram connection")
|
||||
return
|
||||
|
||||
# --- Задача отправки аудио ---
|
||||
async def send_audio():
|
||||
chunks_sent = 0
|
||||
try:
|
||||
stream.start_stream()
|
||||
print("🎤 Stream started, sending audio...")
|
||||
while not stop_event.is_set():
|
||||
if stream.is_active():
|
||||
data = stream.read(4096, exception_on_overflow=False)
|
||||
# Отправка данных (синхронная в этой версии SDK)
|
||||
dg_connection.send(data)
|
||||
chunks_sent += 1
|
||||
if chunks_sent % 50 == 0:
|
||||
print(f".", end="", flush=True)
|
||||
# Уступаем время другим задачам
|
||||
await asyncio.sleep(0.005)
|
||||
except Exception as e:
|
||||
print(f"Audio send error: {e}")
|
||||
finally:
|
||||
stream.stop_stream()
|
||||
print(f"\n🛑 Stream stopped. Chunks sent: {chunks_sent}")
|
||||
|
||||
sender_task = asyncio.create_task(send_audio())
|
||||
|
||||
try:
|
||||
# 1. Ждем начала речи (если задан detection_timeout)
|
||||
if detection_timeout:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
speech_started_event.wait(), timeout=detection_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Если за detection_timeout (5 сек) никто не начал говорить, выходим
|
||||
stop_event.set()
|
||||
|
||||
# 2. Если речь началась (или таймаута нет), ждем завершения (stop_event)
|
||||
# stop_event сработает либо по UtteranceEnd (пауза), либо по общему таймауту
|
||||
if not stop_event.is_set():
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=timeout_seconds)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
pass # Общий таймаут вышел
|
||||
|
||||
stop_event.set()
|
||||
await sender_task
|
||||
# Завершаем соединение и ждем последние результаты
|
||||
dg_connection.finish()
|
||||
|
||||
return self.transcript
|
||||
|
||||
def listen(
|
||||
self,
|
||||
timeout_seconds: float = 7.0,
|
||||
detection_timeout: float = None,
|
||||
lang: str = "ru",
|
||||
) -> str:
|
||||
"""
|
||||
Основной метод: слушает микрофон и возвращает текст.
|
||||
|
||||
Args:
|
||||
timeout_seconds: Максимальная длительность фразы.
|
||||
detection_timeout: Сколько ждать начала речи перед тем как сдаться.
|
||||
lang: Язык ("ru" или "en").
|
||||
"""
|
||||
if not self.dg_client:
|
||||
self.initialize()
|
||||
|
||||
self.current_lang = lang
|
||||
print(f"🎙️ Слушаю ({lang})...")
|
||||
|
||||
last_error = None
|
||||
|
||||
# Делаем 2 попытки на случай сбоя сети
|
||||
for attempt in range(2):
|
||||
# Создаем новое live подключение для каждой сессии
|
||||
dg_connection = self.dg_client.listen.live.v("1")
|
||||
|
||||
try:
|
||||
# Запускаем асинхронный процесс обработки
|
||||
transcript = asyncio.run(
|
||||
self._process_audio(
|
||||
dg_connection, timeout_seconds, detection_timeout
|
||||
)
|
||||
)
|
||||
final_text = transcript.strip() if transcript else ""
|
||||
if final_text:
|
||||
print(f"📝 Распознано: {final_text}")
|
||||
return final_text
|
||||
else:
|
||||
# Если вернулась пустая строка (тишина), считаем это штатным завершением.
|
||||
# Не нужно повторять попытку, как при ошибке сети.
|
||||
return ""
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
if attempt == 0:
|
||||
print("⚠️ Не удалось подключиться к Deepgram, повторяю...")
|
||||
time.sleep(1)
|
||||
|
||||
if last_error:
|
||||
print(f"❌ Ошибка STT: {last_error}")
|
||||
else:
|
||||
print("⚠️ Речь не распознана")
|
||||
return ""
|
||||
|
||||
def cleanup(self):
|
||||
"""Очистка ресурсов."""
|
||||
if self.stream:
|
||||
self.stream.stop_stream()
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
if self.pa:
|
||||
self.pa.terminate()
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
_recognizer = None
|
||||
|
||||
|
||||
def get_recognizer() -> SpeechRecognizer:
|
||||
global _recognizer
|
||||
if _recognizer is None:
|
||||
_recognizer = SpeechRecognizer()
|
||||
return _recognizer
|
||||
|
||||
|
||||
def listen(
|
||||
timeout_seconds: float = 7.0, detection_timeout: float = None, lang: str = "ru"
|
||||
) -> str:
|
||||
"""Внешняя функция для прослушивания."""
|
||||
return get_recognizer().listen(timeout_seconds, detection_timeout, lang)
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""Внешняя функция очистки."""
|
||||
global _recognizer
|
||||
if _recognizer:
|
||||
_recognizer.cleanup()
|
||||
_recognizer = None
|
||||
265
app/audio/tts.py
Normal file
265
app/audio/tts.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Text-to-Speech module using Silero TTS.
|
||||
Generates natural Russian speech.
|
||||
Supports interruption via wake word detection using threading.
|
||||
"""
|
||||
|
||||
# Модуль синтеза речи (TTS - Text-to-Speech).
|
||||
# Использует нейросеть Silero TTS для качественной русской речи.
|
||||
# Также поддерживает прерывание речи, если пользователь скажет "Alexandr".
|
||||
|
||||
import torch
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
import re
|
||||
from ..core.config import TTS_SPEAKER, TTS_EN_SPEAKER, TTS_SAMPLE_RATE
|
||||
|
||||
# Подавляем предупреждения Silero о длинном тексте (мы сами его режем)
|
||||
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
||||
|
||||
|
||||
class TextToSpeech:
|
||||
"""Класс синтеза речи с поддержкой прерывания."""
|
||||
|
||||
def __init__(self):
|
||||
self.model_ru = None
|
||||
self.model_en = None
|
||||
self.sample_rate = TTS_SAMPLE_RATE
|
||||
self.speaker_ru = TTS_SPEAKER
|
||||
self.speaker_en = TTS_EN_SPEAKER
|
||||
self._interrupted = False
|
||||
self._stop_flag = threading.Event()
|
||||
|
||||
def _load_model(self, language: str):
|
||||
"""
|
||||
Загрузка и кэширование модели Silero TTS.
|
||||
Загружается один раз при первом обращении.
|
||||
"""
|
||||
device = torch.device("cpu") # Работаем на процессоре (достаточно быстро)
|
||||
|
||||
if language == "en":
|
||||
if self.model_en:
|
||||
return self.model_en
|
||||
print("📦 Загрузка модели Silero TTS (en)...")
|
||||
model, _ = torch.hub.load(
|
||||
repo_or_dir="snakers4/silero-models",
|
||||
model="silero_tts",
|
||||
language="en",
|
||||
speaker="v3_en",
|
||||
)
|
||||
model.to(device)
|
||||
self.model_en = model
|
||||
return model
|
||||
|
||||
# По умолчанию русский
|
||||
if self.model_ru:
|
||||
return self.model_ru
|
||||
print("📦 Загрузка модели Silero TTS (ru)...")
|
||||
model, _ = torch.hub.load(
|
||||
repo_or_dir="snakers4/silero-models",
|
||||
model="silero_tts",
|
||||
language="ru",
|
||||
speaker="v5_ru",
|
||||
)
|
||||
model.to(device)
|
||||
self.model_ru = model
|
||||
return model
|
||||
|
||||
def initialize(self):
|
||||
"""Предварительная инициализация (прогрев) русской модели."""
|
||||
self._load_model("ru")
|
||||
|
||||
def _split_text(self, text: str, max_length: int = 900) -> list[str]:
|
||||
"""
|
||||
Разбивает длинный текст на части (чанки), так как Silero не принимает >1000 символов.
|
||||
Старается разбивать по предложениям (.!?).
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
# Разбиваем по знакам препинания, сохраняя их
|
||||
parts = re.split(r"([.!?]+\s*)", text)
|
||||
|
||||
current_chunk = ""
|
||||
|
||||
for part in parts:
|
||||
# Если добавление части превысит лимит, сохраняем текущий кусок
|
||||
if len(current_chunk) + len(part) > max_length:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = ""
|
||||
|
||||
current_chunk += part
|
||||
|
||||
# Если даже одна часть огромная (нет знаков препинания), режем жестко по пробелам
|
||||
while len(current_chunk) > max_length:
|
||||
split_idx = current_chunk.rfind(" ", 0, max_length)
|
||||
if split_idx == -1:
|
||||
split_idx = max_length # Если нет пробелов, режем посередине слова
|
||||
|
||||
chunks.append(current_chunk[:split_idx].strip())
|
||||
current_chunk = current_chunk[split_idx:].lstrip()
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return [c for c in chunks if c]
|
||||
|
||||
def speak(self, text: str, check_interrupt=None, language: str = "ru") -> bool:
|
||||
"""
|
||||
Основная функция: генерирует аудио и воспроизводит его.
|
||||
|
||||
Args:
|
||||
text: Текст для озвучки.
|
||||
check_interrupt: Функция, возвращающая True, если надо прерваться (например, check_wakeword_once).
|
||||
language: "ru" или "en".
|
||||
|
||||
Returns:
|
||||
True, если договорил до конца.
|
||||
False, если был прерван.
|
||||
"""
|
||||
if not text.strip():
|
||||
return True
|
||||
|
||||
# Выбор модели
|
||||
if language == "en":
|
||||
model = self._load_model("en")
|
||||
speaker = self.speaker_en
|
||||
else:
|
||||
model = self._load_model("ru")
|
||||
speaker = self.speaker_ru
|
||||
|
||||
# Проверка наличия спикера в модели (защита от ошибок конфига)
|
||||
if hasattr(model, "speakers") and speaker not in model.speakers:
|
||||
if model.speakers:
|
||||
speaker = model.speakers[0]
|
||||
|
||||
# Разбиваем текст на куски
|
||||
chunks = self._split_text(text)
|
||||
total_chunks = len(chunks)
|
||||
|
||||
if total_chunks > 1:
|
||||
print(f"🔊 Озвучивание (частей: {total_chunks}): {text[:50]}...")
|
||||
else:
|
||||
print(f"🔊 Озвучивание: {text[:50]}...")
|
||||
|
||||
self._interrupted = False
|
||||
self._stop_flag.clear()
|
||||
|
||||
success = True
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
if self._interrupted:
|
||||
break
|
||||
|
||||
try:
|
||||
# Генерация аудио (тензор)
|
||||
audio = model.apply_tts(
|
||||
text=chunk, speaker=speaker, sample_rate=self.sample_rate
|
||||
)
|
||||
|
||||
# Конвертация в numpy массив для sounddevice
|
||||
audio_np = audio.numpy()
|
||||
|
||||
if check_interrupt:
|
||||
# Воспроизведение с проверкой прерывания (сложная логика)
|
||||
if not self._play_with_interrupt(audio_np, check_interrupt):
|
||||
success = False
|
||||
break
|
||||
else:
|
||||
# Обычное воспроизведение (блокирующее)
|
||||
sd.play(audio_np, self.sample_rate)
|
||||
sd.wait()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка TTS (часть {i + 1}/{total_chunks}): {e}")
|
||||
success = False
|
||||
|
||||
if success and not self._interrupted:
|
||||
print("✅ Воспроизведение завершено")
|
||||
return True
|
||||
elif self._interrupted:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def _check_interrupt_worker(self, check_interrupt):
|
||||
"""
|
||||
Фоновая функция для потока: постоянно опрашивает check_interrupt.
|
||||
Если вернуло True -> останавливаем звук.
|
||||
"""
|
||||
while not self._stop_flag.is_set():
|
||||
try:
|
||||
if check_interrupt():
|
||||
self._interrupted = True
|
||||
sd.stop() # Немедленная остановка звука
|
||||
print("⏹️ Воспроизведение прервано!")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _play_with_interrupt(self, audio_np: np.ndarray, check_interrupt) -> bool:
|
||||
"""
|
||||
Воспроизводит аудио, параллельно проверяя условие прерывания в отдельном потоке.
|
||||
"""
|
||||
# Запускаем поток-наблюдатель
|
||||
checker_thread = threading.Thread(
|
||||
target=self._check_interrupt_worker, args=(check_interrupt,), daemon=True
|
||||
)
|
||||
checker_thread.start()
|
||||
|
||||
try:
|
||||
# Запускаем воспроизведение (неблокирующее)
|
||||
sd.play(audio_np, self.sample_rate)
|
||||
|
||||
# Ждем окончания воспроизведения в цикле
|
||||
while sd.get_stream().active:
|
||||
if self._interrupted:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
finally:
|
||||
# Сообщаем потоку-наблюдателю, что пора завершаться
|
||||
self._stop_flag.set()
|
||||
checker_thread.join(timeout=0.5)
|
||||
|
||||
if self._interrupted:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def was_interrupted(self) -> bool:
|
||||
"""Был ли прерван последний вызов speak."""
|
||||
return self._interrupted
|
||||
|
||||
|
||||
# Глобальный экземпляр TTS
|
||||
_tts = None
|
||||
|
||||
|
||||
def get_tts() -> TextToSpeech:
|
||||
"""Получить или создать экземпляр TTS."""
|
||||
global _tts
|
||||
if _tts is None:
|
||||
_tts = TextToSpeech()
|
||||
return _tts
|
||||
|
||||
|
||||
def speak(text: str, check_interrupt=None, language: str = "ru") -> bool:
|
||||
"""Внешняя функция для озвучивания."""
|
||||
return get_tts().speak(text, check_interrupt, language)
|
||||
|
||||
|
||||
def was_interrupted() -> bool:
|
||||
"""Проверка флага прерывания."""
|
||||
return get_tts().was_interrupted
|
||||
|
||||
|
||||
def initialize():
|
||||
"""Предварительная загрузка моделей."""
|
||||
get_tts().initialize()
|
||||
180
app/audio/wakeword.py
Normal file
180
app/audio/wakeword.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Wake word detection module using Porcupine.
|
||||
Listens for the "Alexandr" wake word.
|
||||
"""
|
||||
|
||||
# Этот модуль отвечает за "уши" ассистента в режиме ожидания.
|
||||
# Он использует библиотеку Porcupine для эффективного (мало CPU) обнаружения ключевой фразы "Alexandr".
|
||||
|
||||
import pvporcupine
|
||||
import pyaudio
|
||||
import struct
|
||||
from ..core.config import PORCUPINE_ACCESS_KEY, PORCUPINE_KEYWORD_PATH
|
||||
|
||||
|
||||
class WakeWordDetector:
|
||||
"""Класс для обнаружения wake word с использованием Porcupine."""
|
||||
|
||||
def __init__(self):
|
||||
self.porcupine = None
|
||||
self.audio_stream = None
|
||||
self.pa = None
|
||||
self._stream_closed = True # Флаг состояния потока (закрыт/открыт)
|
||||
|
||||
def initialize(self):
|
||||
"""Инициализация Porcupine и PyAudio."""
|
||||
# Создаем экземпляр Porcupine с нашим ключом доступа и файлом модели (.ppn)
|
||||
self.porcupine = pvporcupine.create(
|
||||
access_key=PORCUPINE_ACCESS_KEY, keyword_paths=[str(PORCUPINE_KEYWORD_PATH)]
|
||||
)
|
||||
|
||||
self.pa = pyaudio.PyAudio()
|
||||
self._open_stream()
|
||||
print("🎤 Ожидание wake word 'Alexandr'...")
|
||||
|
||||
def _open_stream(self):
|
||||
"""Открытие аудиопотока с микрофона."""
|
||||
if self.audio_stream and not self._stream_closed:
|
||||
return # Уже открыт
|
||||
|
||||
# Если был открыт старый поток, пробуем закрыть
|
||||
if self.audio_stream:
|
||||
try:
|
||||
self.audio_stream.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Открываем поток с параметрами, которые требует Porcupine
|
||||
self.audio_stream = self.pa.open(
|
||||
rate=self.porcupine.sample_rate,
|
||||
channels=1,
|
||||
format=pyaudio.paInt16,
|
||||
input=True,
|
||||
frames_per_buffer=self.porcupine.frame_length,
|
||||
)
|
||||
self._stream_closed = False
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Явная остановка и закрытие потока (чтобы освободить микрофон для других задач)."""
|
||||
if self.audio_stream and not self._stream_closed:
|
||||
try:
|
||||
self.audio_stream.stop_stream()
|
||||
self.audio_stream.close()
|
||||
except:
|
||||
pass
|
||||
self._stream_closed = True
|
||||
|
||||
def wait_for_wakeword(self, timeout: float = None) -> bool:
|
||||
"""
|
||||
Блокирующая функция: ждет, пока не будет услышана фраза "Alexandr"
|
||||
или пока не истечет timeout.
|
||||
|
||||
Args:
|
||||
timeout: Максимальное время ожидания в секундах. None = ждать бесконечно.
|
||||
|
||||
Returns:
|
||||
True, если фраза обнаружена. False, если вышел таймаут.
|
||||
"""
|
||||
import time
|
||||
|
||||
if not self.porcupine:
|
||||
self.initialize()
|
||||
|
||||
# Убеждаемся, что поток открыт
|
||||
self._open_stream()
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
# Проверка таймаута
|
||||
if timeout and (time.time() - start_time > timeout):
|
||||
return False
|
||||
|
||||
# Читаем небольшой кусочек аудио (frame)
|
||||
pcm = self.audio_stream.read(
|
||||
self.porcupine.frame_length, exception_on_overflow=False
|
||||
)
|
||||
# Конвертируем байты в кортеж чисел (требование Porcupine)
|
||||
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
|
||||
|
||||
# Обрабатываем фрейм через Porcupine
|
||||
keyword_index = self.porcupine.process(pcm)
|
||||
|
||||
# Если keyword_index >= 0, значит ключевое слово обнаружено
|
||||
if keyword_index >= 0:
|
||||
print("✅ Wake word обнаружен!")
|
||||
# Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram)
|
||||
self.stop_monitoring()
|
||||
return True
|
||||
|
||||
def check_wakeword_once(self) -> bool:
|
||||
"""
|
||||
Неблокирующая проверка (один кадр).
|
||||
Используется во время того, как ассистент говорит (TTS),
|
||||
чтобы проверить, не пытается ли пользователь его перебить.
|
||||
|
||||
Returns:
|
||||
True, если фраза обнаружена прямо сейчас.
|
||||
"""
|
||||
if not self.porcupine:
|
||||
self.initialize()
|
||||
|
||||
try:
|
||||
self._open_stream()
|
||||
|
||||
pcm = self.audio_stream.read(
|
||||
self.porcupine.frame_length, exception_on_overflow=False
|
||||
)
|
||||
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
|
||||
|
||||
keyword_index = self.porcupine.process(pcm)
|
||||
if keyword_index >= 0:
|
||||
print("🛑 Wake word обнаружен во время ответа!")
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""Освобождение ресурсов при выходе."""
|
||||
self.stop_monitoring()
|
||||
if self.pa:
|
||||
self.pa.terminate()
|
||||
if self.porcupine:
|
||||
self.porcupine.delete()
|
||||
|
||||
|
||||
# Глобальный экземпляр детектора (Singleton)
|
||||
_detector = None
|
||||
|
||||
|
||||
def get_detector() -> WakeWordDetector:
|
||||
"""Получить или создать глобальный экземпляр детектора."""
|
||||
global _detector
|
||||
if _detector is None:
|
||||
_detector = WakeWordDetector()
|
||||
return _detector
|
||||
|
||||
|
||||
def wait_for_wakeword(timeout: float = None) -> bool:
|
||||
"""Внешняя функция для ожидания wake word."""
|
||||
return get_detector().wait_for_wakeword(timeout)
|
||||
|
||||
|
||||
def stop_monitoring():
|
||||
"""Внешняя функция для остановки мониторинга."""
|
||||
if _detector:
|
||||
_detector.stop_monitoring()
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""Внешняя функция очистки ресурсов."""
|
||||
global _detector
|
||||
if _detector:
|
||||
_detector.cleanup()
|
||||
_detector = None
|
||||
|
||||
|
||||
def check_wakeword_once() -> bool:
|
||||
"""Внешняя функция для быстрой проверки."""
|
||||
return get_detector().check_wakeword_once()
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
AI module for Perplexity API integration.
|
||||
Sends user queries and receives AI responses.
|
||||
"""
|
||||
"""AI module for Perplexity API integration."""
|
||||
|
||||
# Модуль общения с искусственным интеллектом (Perplexity API).
|
||||
# Обрабатывает запросы пользователя и переводы.
|
||||
|
||||
import requests
|
||||
from config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
|
||||
from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
|
||||
|
||||
|
||||
# System prompt for the AI
|
||||
# Системный промпт (инструкция) для AI.
|
||||
# Задает личность ассистента: имя "Александр", стиль общения, краткость.
|
||||
SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
|
||||
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
|
||||
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
|
||||
@@ -16,79 +17,86 @@ SYSTEM_PROMPT = """Ты — Александр, умный голосовой а
|
||||
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
|
||||
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные."""
|
||||
|
||||
# Системный промпт для режима переводчика.
|
||||
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
|
||||
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
|
||||
Translate from {source} to {target}.
|
||||
Return only the translated text, without quotes, comments, or explanations."""
|
||||
|
||||
|
||||
def ask_ai(messages_history: list) -> str:
|
||||
def _send_request(messages, max_tokens, temperature, error_text):
|
||||
"""
|
||||
Send a message history to Perplexity AI and get a response.
|
||||
Внутренняя функция для отправки HTTP-запроса к Perplexity API.
|
||||
|
||||
Args:
|
||||
messages_history: List of dictionaries with role and content
|
||||
e.g., [{"role": "user", "content": "Hi"}]
|
||||
|
||||
Returns:
|
||||
AI response text
|
||||
messages: Список сообщений (история чата).
|
||||
max_tokens: Максимальная длина ответа.
|
||||
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
|
||||
error_text: Текст ошибки для пользователя в случае сбоя.
|
||||
"""
|
||||
if not messages_history:
|
||||
return "Извините, я не расслышал вашу команду."
|
||||
|
||||
# Extract the last user message for logging
|
||||
last_user_message = next(
|
||||
(m["content"] for m in reversed(messages_history) if m["role"] == "user"),
|
||||
"Unknown",
|
||||
)
|
||||
print(f"🤖 Запрос к AI: {last_user_message}")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Prepend system prompt to the history
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history)
|
||||
|
||||
payload = {
|
||||
"model": PERPLEXITY_MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": 500,
|
||||
"temperature": 1.0,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx)
|
||||
data = response.json()
|
||||
ai_response = data["choices"][0]["message"]["content"]
|
||||
print(f"💬 Ответ AI: {ai_response[:100]}...")
|
||||
return ai_response
|
||||
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except requests.exceptions.Timeout:
|
||||
return "Извините, сервер не отвечает. Попробуйте позже."
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Ошибка API: {e}")
|
||||
return "Произошла ошибка при обращении к AI. Попробуйте ещё раз."
|
||||
return error_text
|
||||
except (KeyError, IndexError) as e:
|
||||
print(f"❌ Ошибка парсинга ответа: {e}")
|
||||
return "Не удалось обработать ответ от AI."
|
||||
|
||||
|
||||
def ask_ai(messages_history: list) -> str:
|
||||
"""
|
||||
Запрос к AI в режиме чата.
|
||||
Принимает историю переписки, добавляет SYSTEM_PROMPT и отправляет запрос.
|
||||
"""
|
||||
if not messages_history:
|
||||
return "Извините, я не расслышал вашу команду."
|
||||
|
||||
# Логирование последнего запроса
|
||||
last_user_message = "Unknown"
|
||||
for msg in reversed(messages_history):
|
||||
if msg["role"] == "user":
|
||||
last_user_message = msg["content"]
|
||||
break
|
||||
print(f"🤖 Запрос к AI: {last_user_message}")
|
||||
|
||||
# Формируем полный список сообщений с системной инструкцией в начале
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history)
|
||||
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=500,
|
||||
temperature=1.0, # Высокая температура для более живого общения
|
||||
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
|
||||
)
|
||||
|
||||
if response:
|
||||
print(f"💬 Ответ AI: {response[:100]}...")
|
||||
return response
|
||||
|
||||
|
||||
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||
"""
|
||||
Translate text using Perplexity AI.
|
||||
|
||||
Args:
|
||||
text: Text to translate
|
||||
source_lang: Source language code ("ru" or "en")
|
||||
target_lang: Target language code ("ru" or "en")
|
||||
|
||||
Returns:
|
||||
Translated text
|
||||
Запрос к AI в режиме перевода.
|
||||
Использует специальный промпт для переводчика.
|
||||
"""
|
||||
if not text:
|
||||
return "Извините, я не расслышал текст для перевода."
|
||||
@@ -99,11 +107,7 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||
|
||||
print(f"🌍 Перевод: {source_name} -> {target_name}: {text[:60]}...")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Формируем промпт с подстановкой языков
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
@@ -114,28 +118,10 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
|
||||
payload = {
|
||||
"model": PERPLEXITY_MODEL,
|
||||
"messages": messages,
|
||||
"max_tokens": 400,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
ai_response = data["choices"][0]["message"]["content"]
|
||||
return ai_response.strip()
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return "Извините, сервер не отвечает. Попробуйте позже."
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Ошибка API перевода: {e}")
|
||||
return "Произошла ошибка при переводе. Попробуйте ещё раз."
|
||||
except (KeyError, IndexError) as e:
|
||||
print(f"❌ Ошибка парсинга ответа перевода: {e}")
|
||||
return "Не удалось обработать перевод."
|
||||
response = _send_request(
|
||||
messages,
|
||||
max_tokens=400,
|
||||
temperature=0.2, # Низкая температура для точности перевода
|
||||
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
|
||||
)
|
||||
return response.strip()
|
||||
@@ -4,43 +4,49 @@ Removes markdown formatting and special characters from AI responses.
|
||||
Handles complex number-to-text conversion for Russian language.
|
||||
"""
|
||||
|
||||
# Модуль очистки текста перед озвучкой.
|
||||
# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы.
|
||||
# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей").
|
||||
# Это критически важно для качественного русского TTS.
|
||||
|
||||
import re
|
||||
import pymorphy3
|
||||
from num2words import num2words
|
||||
|
||||
# Initialize morphological analyzer
|
||||
# Инициализация морфологического анализатора (для определения падежей)
|
||||
morph = pymorphy3.MorphAnalyzer()
|
||||
|
||||
# Preposition to case mapping (simplified heuristics)
|
||||
# Карта предлогов и падежей.
|
||||
# Помогает понять, в какой падеж ставить число после предлога.
|
||||
PREPOSITION_CASES = {
|
||||
"в": "loct", # Prepositional (Locative 2) or Accusative. 'v godu' -> loct
|
||||
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
|
||||
"во": "loct",
|
||||
"на": "accs", # Dates: 'na 5 maya' -> Accusative (na pyatoe)
|
||||
"на": "accs", # На какое число? (Винительный) - для дат.
|
||||
"о": "loct",
|
||||
"об": "loct",
|
||||
"обо": "loct",
|
||||
"при": "loct",
|
||||
"у": "gent",
|
||||
"у": "gent", # У кого/чего? (Родительный)
|
||||
"от": "gent",
|
||||
"до": "gent",
|
||||
"из": "gent",
|
||||
"с": "gent", # or ablt (instrumental)
|
||||
"с": "gent", # Или Творительный. Но чаще Родительный (с 5 числа).
|
||||
"со": "gent",
|
||||
"без": "gent",
|
||||
"для": "gent",
|
||||
"вокруг": "gent",
|
||||
"после": "gent",
|
||||
"к": "datv",
|
||||
"к": "datv", # К кому/чему? (Дательный)
|
||||
"ко": "datv",
|
||||
"по": "datv", # or accs for dates (limit). Heuristic: datv defaults usually.
|
||||
"над": "ablt",
|
||||
"по": "datv",
|
||||
"над": "ablt", # Над кем/чем? (Творительный)
|
||||
"под": "ablt",
|
||||
"перед": "ablt",
|
||||
"за": "ablt", # or acc
|
||||
"за": "ablt",
|
||||
"между": "ablt",
|
||||
}
|
||||
|
||||
# Mapping pymorphy cases to num2words cases
|
||||
# Соответствие падежей pymorphy и библиотеки num2words
|
||||
PYMORPHY_TO_NUM2WORDS = {
|
||||
"nomn": "nominative",
|
||||
"gent": "genitive",
|
||||
@@ -48,13 +54,13 @@ PYMORPHY_TO_NUM2WORDS = {
|
||||
"accs": "accusative",
|
||||
"ablt": "instrumental",
|
||||
"loct": "prepositional",
|
||||
"voct": "nominative", # Fallback
|
||||
"voct": "nominative",
|
||||
"gen2": "genitive",
|
||||
"acc2": "accusative",
|
||||
"loc2": "prepositional",
|
||||
}
|
||||
|
||||
# Month names in Genitive case (as they appear in dates)
|
||||
# Названия месяцев в родительном падеже (для поиска дат в тексте)
|
||||
MONTHS_GENITIVE = [
|
||||
"января",
|
||||
"февраля",
|
||||
@@ -72,16 +78,20 @@ MONTHS_GENITIVE = [
|
||||
|
||||
|
||||
def get_case_from_preposition(prep_token):
|
||||
"""Return pymorphy case based on preposition."""
|
||||
"""Определяет падеж по предлогу."""
|
||||
if not prep_token:
|
||||
return None
|
||||
return PREPOSITION_CASES.get(prep_token.lower())
|
||||
|
||||
|
||||
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
|
||||
"""Convert a number string to words with specific parameters."""
|
||||
"""
|
||||
Обертка над num2words для конвертации числа в строку.
|
||||
cardinal - количественное (один, два)
|
||||
ordinal - порядковое (первый, второй)
|
||||
"""
|
||||
try:
|
||||
# Handle floats
|
||||
# Обработка дробей (замена запятой на точку)
|
||||
if "." in number_str or "," in number_str:
|
||||
num_val = float(number_str.replace(",", "."))
|
||||
else:
|
||||
@@ -95,31 +105,25 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende
|
||||
|
||||
def numbers_to_words(text: str) -> str:
|
||||
"""
|
||||
Intelligent conversion of digits in text to Russian words.
|
||||
Handles years, dates, and basic case agreement.
|
||||
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. Identify "Year" patterns: "1999 год", "в 2024 году"
|
||||
# 1. Обработка годов: "в 1999 году", "2024 год"
|
||||
def replace_year_match(match):
|
||||
full_str = match.group(0)
|
||||
prep = match.group(1) # Could be None
|
||||
year_str = match.group(2)
|
||||
year_word = match.group(3) # год, году, года...
|
||||
prep = match.group(1) # Предлог (в, с, к...)
|
||||
year_str = match.group(2) # Само число
|
||||
year_word = match.group(3) # Слово "год", "году" и т.д.
|
||||
|
||||
# Определяем падеж слова "год" через pymorphy
|
||||
parsed = morph.parse(year_word)[0]
|
||||
case_tag = parsed.tag.case
|
||||
|
||||
if (
|
||||
prep
|
||||
and prep.strip().lower() in ["в", "во"]
|
||||
and case_tag in ["accs", "nomn"]
|
||||
):
|
||||
pass
|
||||
|
||||
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
|
||||
|
||||
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
|
||||
words = convert_number(
|
||||
year_str, context_type="ordinal", case=nw_case, gender="m"
|
||||
)
|
||||
@@ -127,15 +131,14 @@ def numbers_to_words(text: str) -> str:
|
||||
prefix = f"{prep} " if prep else ""
|
||||
return f"{prefix}{words} {year_word}"
|
||||
|
||||
# Регулярка для годов
|
||||
text = re.sub(
|
||||
r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b",
|
||||
replace_year_match,
|
||||
text,
|
||||
)
|
||||
|
||||
# 2. Identify "Date" patterns: "25 июня", "с 1 мая"
|
||||
# Matches: (Preposition)? (Day) (Month_Genitive)
|
||||
# Day is usually 1-31.
|
||||
# 2. Обработка дат: "25 июня", "с 1 мая"
|
||||
month_regex = "|".join(MONTHS_GENITIVE)
|
||||
|
||||
def replace_date_match(match):
|
||||
@@ -143,46 +146,39 @@ def numbers_to_words(text: str) -> str:
|
||||
day_str = match.group(2)
|
||||
month_word = match.group(3)
|
||||
|
||||
# Determine case
|
||||
# Default to Genitive ("25 июня" -> "двадцать пятого июня")
|
||||
# По умолчанию родительный падеж ("двадцать пятого июня")
|
||||
case = "genitive"
|
||||
|
||||
if prep:
|
||||
prep_clean = prep.strip().lower()
|
||||
# Specific overrides for dates
|
||||
# Специфичные правила для дат
|
||||
if prep_clean == "на":
|
||||
case = "accusative" # на 5 мая -> на пятое
|
||||
case = "accusative" # на пятое мая
|
||||
elif prep_clean == "по":
|
||||
case = "accusative" # по 5 мая -> по пятое (limit)
|
||||
case = "accusative" # по пятое
|
||||
elif prep_clean == "к":
|
||||
case = "dative" # к 5 мая -> к пятому
|
||||
case = "dative" # к пятому
|
||||
elif prep_clean in ["с", "до", "от"]:
|
||||
case = "genitive" # с 5 мая -> с пятого
|
||||
case = "genitive" # с пятого
|
||||
else:
|
||||
# Fallback to general preposition map
|
||||
morph_case = get_case_from_preposition(prep_clean)
|
||||
if morph_case:
|
||||
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
|
||||
|
||||
# Convert to Ordinal
|
||||
# Dates are neuter ("число" implies neuter: "пятое", "пятого")
|
||||
# However, num2words for genitive ordinal:
|
||||
# 5, ordinal, genitive -> "пятого" (masc/neut are same)
|
||||
# 5, ordinal, accusative -> "пятое" (neuter) vs "пятый" (masc inanimate?)
|
||||
# Let's specify gender='n' (neuter) for dates to be safe (пятое, пятого, пятому).
|
||||
|
||||
# Используем средний род ('n') для дат (число - средний род: пятое, пятого)
|
||||
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
|
||||
|
||||
prefix = f"{prep} " if prep else ""
|
||||
return f"{prefix}{words} {month_word}"
|
||||
|
||||
# Конкатенация regex для месяцев (ВАЖНО: month_regex должен быть вставлен в строку)
|
||||
text = re.sub(
|
||||
r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+(" + month_regex + r")\b",
|
||||
r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+({month_regex})\b",
|
||||
replace_date_match,
|
||||
text,
|
||||
)
|
||||
|
||||
# 3. Handle remaining numbers (Cardinals)
|
||||
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
|
||||
def replace_cardinal_match(match):
|
||||
prep = match.group(1)
|
||||
num_str = match.group(2)
|
||||
@@ -209,61 +205,59 @@ def numbers_to_words(text: str) -> str:
|
||||
|
||||
def clean_response(text: str, language: str = "ru") -> str:
|
||||
"""
|
||||
Clean AI response from markdown formatting and special characters.
|
||||
Основная функция очистки.
|
||||
Убирает Markdown, ссылки, мусор и преобразует числа.
|
||||
|
||||
Args:
|
||||
text: Raw AI response with possible markdown
|
||||
language: Target language for output (affects post-processing)
|
||||
|
||||
Returns:
|
||||
Clean text suitable for TTS
|
||||
text: Сырой текст от AI.
|
||||
language: Язык (для конвертации чисел, работает только для ru).
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove citation references like [1], [2], [citation], etc.
|
||||
# Using hex escapes for brackets to avoid escaping issues
|
||||
# Удаление ссылок на источники [1], [citation needed]
|
||||
text = re.sub(r"\x5B\d+\x5D", "", text)
|
||||
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"\x5Bsource\x5D", "", text, flags=re.IGNORECASE)
|
||||
|
||||
# Remove markdown bold **text** and __text__
|
||||
# Удаление жирного шрифта **text** и __text__
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
|
||||
# Remove markdown italic *text* and _text_
|
||||
# Удаление курсива *text* и _text_
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
||||
|
||||
# Remove markdown strikethrough ~~text~~
|
||||
# Удаление зачеркнутого ~~text~~
|
||||
text = re.sub(r"~~(.+?)~~", r"\1", text)
|
||||
|
||||
# Remove markdown headers # ## ### etc.
|
||||
# Удаление заголовков Markdown (# Header)
|
||||
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove markdown links [text](url) -> text
|
||||
# Удаление ссылок [text](url) -> оставляем только text
|
||||
# \x5B = [, \x5D = ]
|
||||
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
|
||||
|
||||
# Remove markdown images 
|
||||
# Удаление картинок  -> удаляем полностью
|
||||
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
|
||||
|
||||
# Remove inline code `code`
|
||||
# Удаление inline кода `code`
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
|
||||
# Remove code blocks ```code```
|
||||
# Удаление блоков кода ```code```
|
||||
text = re.sub(r"```[\s\S]*?```", "", text)
|
||||
|
||||
# Remove markdown list markers (-, *, +, numbered)
|
||||
# Удаление маркеров списков (-, *, 1.)
|
||||
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove blockquotes
|
||||
# Удаление цитат >
|
||||
text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove horizontal rules
|
||||
# Удаление горизонтальных линий ---
|
||||
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove HTML tags if any
|
||||
# Удаление HTML тегов
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
# Remove informal slang greetings at the beginning of sentences/responses
|
||||
@@ -282,7 +276,4 @@ def clean_response(text: str, language: str = "ru") -> str:
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
text = re.sub(r" +", " ", text)
|
||||
|
||||
# Clean up and return
|
||||
text = text.strip()
|
||||
|
||||
return text
|
||||
return text.strip()
|
||||
58
app/core/config.py
Normal file
58
app/core/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Configuration module for smart speaker.
|
||||
Loads environment variables from .env file.
|
||||
"""
|
||||
|
||||
# Этот модуль отвечает за конфигурацию всего проекта.
|
||||
# Он загружает настройки из файла .env (переменные окружения) и определяет константы.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Базовая директория проекта (корневая папка, где лежит .env)
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Загружаем переменные из файла .env в корневом каталоге
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
# --- Настройки AI (Perplexity) ---
|
||||
# API ключ для доступа к нейросети
|
||||
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
|
||||
# Модель, которую будем использовать (по умолчанию llama-3.1-sonar-small-128k-chat)
|
||||
PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-small-128k-chat")
|
||||
PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"
|
||||
|
||||
# --- Настройки распознавания речи (Deepgram) ---
|
||||
# Ключ для облачного STT (Speech-to-Text)
|
||||
DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
||||
|
||||
# --- Настройки активации голосом (Porcupine) ---
|
||||
# Ключ доступа PicoVoice
|
||||
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
|
||||
# Путь к файлу модели ключевого слова (.ppn), который лежит в папке assets/models
|
||||
PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Alexandr_en_linux_v4_0_0.ppn"
|
||||
|
||||
# --- Настройки локального распознавания (Vosk) ---
|
||||
# Используется для стоп-команд и будильника, когда не нужен интернет
|
||||
VOSK_MODEL_PATH = BASE_DIR / "assets" / "models" / "vosk-model-ru-0.42"
|
||||
|
||||
# --- Параметры аудио ---
|
||||
# Частота дискретизации для микрофона (стандарт для распознавания речи)
|
||||
SAMPLE_RATE = 16000
|
||||
CHANNELS = 1
|
||||
|
||||
# --- Настройка времени ---
|
||||
# Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно
|
||||
import time
|
||||
|
||||
os.environ["TZ"] = "Europe/Moscow"
|
||||
time.tzset()
|
||||
|
||||
# --- Настройки синтеза речи (TTS) ---
|
||||
# Голос для русского языка (eugene - мужской голос)
|
||||
TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene
|
||||
# Голос для английского языка
|
||||
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
||||
# Частота дискретизации для воспроизведения (качество звука)
|
||||
TTS_SAMPLE_RATE = 48000
|
||||
0
app/features/__init__.py
Normal file
0
app/features/__init__.py
Normal file
@@ -1,19 +1,21 @@
|
||||
"""
|
||||
Alarm clock module.
|
||||
Handles alarm scheduling, persistence, and playback.
|
||||
"""
|
||||
"""Alarm clock module."""
|
||||
|
||||
# Модуль будильника.
|
||||
# Отвечает за хранение будильников (в JSON файле), их проверку и воспроизведение звука.
|
||||
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from config import BASE_DIR
|
||||
from local_stt import listen_for_keywords
|
||||
from ..core.config import BASE_DIR
|
||||
from ..audio.local_stt import listen_for_keywords
|
||||
|
||||
# Файл базы данных будильников
|
||||
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
|
||||
# Звуковой файл сигнала
|
||||
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
|
||||
|
||||
ALARM_FILE = BASE_DIR / "alarms.json"
|
||||
ALARM_SOUND = BASE_DIR / "Apex-1.mp3"
|
||||
|
||||
class AlarmClock:
|
||||
def __init__(self):
|
||||
@@ -21,7 +23,7 @@ class AlarmClock:
|
||||
self.load_alarms()
|
||||
|
||||
def load_alarms(self):
|
||||
"""Load alarms from JSON file."""
|
||||
"""Загрузка списка будильников из JSON файла."""
|
||||
if ALARM_FILE.exists():
|
||||
try:
|
||||
with open(ALARM_FILE, "r", encoding="utf-8") as f:
|
||||
@@ -31,7 +33,7 @@ class AlarmClock:
|
||||
self.alarms = []
|
||||
|
||||
def save_alarms(self):
|
||||
"""Save alarms to JSON file."""
|
||||
"""Сохранение списка будильников в JSON файл."""
|
||||
try:
|
||||
with open(ALARM_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self.alarms, f, indent=4)
|
||||
@@ -39,86 +41,92 @@ class AlarmClock:
|
||||
print(f"❌ Ошибка сохранения будильников: {e}")
|
||||
|
||||
def add_alarm(self, hour: int, minute: int):
|
||||
"""Add a new alarm."""
|
||||
# Check if already exists
|
||||
"""Добавление нового будильника (или обновление существующего)."""
|
||||
for alarm in self.alarms:
|
||||
if alarm["hour"] == hour and alarm["minute"] == minute:
|
||||
alarm["active"] = True
|
||||
self.save_alarms()
|
||||
return
|
||||
|
||||
self.alarms.append({
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
"active": True
|
||||
})
|
||||
|
||||
self.alarms.append({"hour": hour, "minute": minute, "active": True})
|
||||
self.save_alarms()
|
||||
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}")
|
||||
|
||||
def cancel_all_alarms(self):
|
||||
"""Cancel all active alarms."""
|
||||
"""Выключение (деактивация) всех будильников."""
|
||||
for alarm in self.alarms:
|
||||
alarm["active"] = False
|
||||
self.save_alarms()
|
||||
print("🔕 Все будильники отменены.")
|
||||
|
||||
def check_alarms(self):
|
||||
"""Check if any alarm should trigger now. Returns True if triggered."""
|
||||
"""
|
||||
Проверка: не пора ли звенеть?
|
||||
Вызывается в главном цикле.
|
||||
Возвращает True, если будильник сработал.
|
||||
"""
|
||||
now = datetime.now()
|
||||
triggered = False
|
||||
|
||||
|
||||
for alarm in self.alarms:
|
||||
if alarm["active"]:
|
||||
if alarm["hour"] == now.hour and alarm["minute"] == now.minute:
|
||||
# Prevent re-triggering within the same minute?
|
||||
# We should disable it immediately or track last trigger time.
|
||||
# For simple logic: disable it (one-time alarm).
|
||||
|
||||
# But wait, checking every second?
|
||||
# If I disable it, it won't ring for the whole minute.
|
||||
# Correct.
|
||||
print(f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}")
|
||||
alarm["active"] = False
|
||||
print(
|
||||
f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
|
||||
)
|
||||
alarm["active"] = (
|
||||
False # Одноразовый будильник, выключаем после срабатывания
|
||||
)
|
||||
triggered = True
|
||||
self.trigger_alarm()
|
||||
break # Trigger one at a time
|
||||
|
||||
self.trigger_alarm() # Запуск звука и ожидание стоп-слова
|
||||
break # Звоним только один за раз
|
||||
|
||||
if triggered:
|
||||
self.save_alarms()
|
||||
return True
|
||||
return False
|
||||
|
||||
def trigger_alarm(self):
|
||||
"""Play alarm sound and wait for stop command."""
|
||||
"""
|
||||
Логика срабатывания будильника.
|
||||
Запускает воспроизведение MP3 через mpg123 и слушает команду "Стоп".
|
||||
Использует локальное распознавание (Vosk), чтобы не зависеть от интернета.
|
||||
"""
|
||||
print("🔔 БУДИЛЬНИК ЗВОНИТ! (Скажите 'Стоп' или 'Александр стоп')")
|
||||
|
||||
# Start playing sound in loop
|
||||
# -q for quiet (no output)
|
||||
# --loop -1 for infinite loop
|
||||
|
||||
# Запуск плеера mpg123 в бесконечном цикле (--loop -1)
|
||||
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
|
||||
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(cmd)
|
||||
except FileNotFoundError:
|
||||
print("❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123")
|
||||
print(
|
||||
"❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Listen for stop command using local Vosk
|
||||
# Loop until stop word is heard
|
||||
stop_words = ["стоп", "хватит", "тихо", "замолчи", "отмена", "александр стоп"]
|
||||
|
||||
stop_words = [
|
||||
"стоп",
|
||||
"хватит",
|
||||
"тихо",
|
||||
"замолчи",
|
||||
"отмена",
|
||||
"александр стоп",
|
||||
]
|
||||
|
||||
# Цикл ожидания стоп-команды
|
||||
while True:
|
||||
# Listen in short bursts to be responsive
|
||||
# Слушаем локально (без интернета)
|
||||
text = listen_for_keywords(stop_words, timeout=3.0)
|
||||
if text:
|
||||
print(f"🛑 Будильник остановлен по команде: '{text}'")
|
||||
break
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка во время будильника: {e}")
|
||||
finally:
|
||||
# Kill the player
|
||||
# Обязательно убиваем процесс плеера
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1)
|
||||
@@ -128,65 +136,53 @@ class AlarmClock:
|
||||
|
||||
def parse_command(self, text: str) -> str | None:
|
||||
"""
|
||||
Parse user text for alarm commands.
|
||||
Returns response string if command handled, None otherwise.
|
||||
Парсинг команды установки будильника из текста.
|
||||
Примеры: "разбуди в 7:30", "будильник на 8 утра".
|
||||
"""
|
||||
text = text.lower()
|
||||
if "будильник" not in text and "разбуди" not in text:
|
||||
return None
|
||||
|
||||
|
||||
if "отмени" in text:
|
||||
self.cancel_all_alarms()
|
||||
return "Хорошо, я отменил все будильники."
|
||||
|
||||
# Regex to find time: HH:MM, HH-MM, HH MM, HH часов MM минут
|
||||
# 1. "07:30", "7:30"
|
||||
match = re.search(r'\b(\d{1,2})[:.-](\d{2})\b', text)
|
||||
# Поиск формата "7:30", "7.30"
|
||||
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
|
||||
if match:
|
||||
h, m = int(match.group(1)), int(match.group(2))
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
self.add_alarm(h, m)
|
||||
return f"Я установил будильник на {h} часов {m} минут."
|
||||
|
||||
# 2. "7 часов 30 минут" or "7 30"
|
||||
# Search for pattern: digits ... (digits)?
|
||||
# Complex to separate from other numbers.
|
||||
|
||||
# Simple heuristics:
|
||||
words = text.split()
|
||||
nums = [int(s) for s in text.split() if s.isdigit()]
|
||||
|
||||
# "на 7" -> 7:00
|
||||
if "на" in words or "в" in words:
|
||||
# Try to find number after preposition
|
||||
pass
|
||||
|
||||
# Let's rely on explicit digit search if regex failed
|
||||
# Patterns: "на 8", "на 8 30", "на 8 часов 30 минут", "на 8 часов"
|
||||
|
||||
# Regex to capture hour and optional minute
|
||||
# Matches: "на <H> [часов] [M] [минут]"
|
||||
match_time = re.search(r'на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?', text)
|
||||
|
||||
# Поиск формата словами "на 7 часов 15 минут"
|
||||
match_time = re.search(
|
||||
r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?",
|
||||
text,
|
||||
)
|
||||
|
||||
if match_time:
|
||||
h = int(match_time.group(1))
|
||||
m = int(match_time.group(2)) if match_time.group(2) else 0
|
||||
|
||||
# Handle AM/PM if specified
|
||||
|
||||
# Умная коррекция времени (если говорят "в 8", а сейчас 9, то это скорее 8 вечера или 8 утра завтра)
|
||||
# Здесь простая логика AM/PM
|
||||
if "вечера" in text and h < 12:
|
||||
h += 12
|
||||
elif "утра" in text and h == 12:
|
||||
h = 0
|
||||
|
||||
|
||||
if 0 <= h <= 23 and 0 <= m <= 59:
|
||||
self.add_alarm(h, m)
|
||||
return f"Хорошо, разбужу вас в {h}:{m:02d}."
|
||||
|
||||
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
|
||||
|
||||
# Global instance
|
||||
|
||||
# Глобальный экземпляр
|
||||
_alarm_clock = None
|
||||
|
||||
|
||||
def get_alarm_clock():
|
||||
global _alarm_clock
|
||||
if _alarm_clock is None:
|
||||
348
app/main.py
Normal file
348
app/main.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Smart Speaker - Main Application
|
||||
Голосовой ассистент с wake word detection, STT, AI и TTS.
|
||||
|
||||
Flow:
|
||||
1. Wait for wake word ("Alexandr")
|
||||
2. Listen to user speech (STT)
|
||||
3. Send query to AI (Perplexity)
|
||||
4. Clean response from markdown
|
||||
5. Speak response (TTS)
|
||||
6. Loop back to step 1
|
||||
"""
|
||||
|
||||
# Главный файл приложения (`main.py`).
|
||||
# Здесь находится основной бесконечный цикл, который связывает все компоненты воедино.
|
||||
|
||||
import signal
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
# Импорт наших модулей
|
||||
from .audio.wakeword import (
|
||||
wait_for_wakeword,
|
||||
cleanup as cleanup_wakeword,
|
||||
check_wakeword_once,
|
||||
stop_monitoring as stop_wakeword_monitoring,
|
||||
)
|
||||
from .audio.stt import listen, cleanup as cleanup_stt, get_recognizer
|
||||
from .core.ai import ask_ai, translate_text
|
||||
from .core.cleaner import clean_response
|
||||
from .audio.tts import speak, initialize as init_tts
|
||||
from .audio.sound_level import set_volume, parse_volume_text
|
||||
from .features.alarm import get_alarm_clock
|
||||
|
||||
# Список стоп-слов, чтобы прервать диалог или остановить ассистента
|
||||
STOP_WORDS = {
|
||||
"стоп",
|
||||
"хватит",
|
||||
"перестань",
|
||||
"замолчи",
|
||||
"прекрати",
|
||||
"тихо",
|
||||
"stop",
|
||||
}
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""
|
||||
Обработчик сигнала Ctrl+C.
|
||||
Позволяет корректно завершить работу программы, освободив ресурсы (микрофон, модели).
|
||||
"""
|
||||
print("\n\n👋 Завершение работы...")
|
||||
cleanup_wakeword() # Остановка Porcupine
|
||||
cleanup_stt() # Остановка Deepgram
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def parse_translation_request(text: str):
|
||||
"""
|
||||
Определяет, является ли фраза запросом на перевод.
|
||||
|
||||
Пример: "Переведи на английский привет мир"
|
||||
Возвращает словарь: {'source_lang': 'ru', 'target_lang': 'en', 'text': 'привет мир'}
|
||||
Или None, если это не запрос перевода.
|
||||
"""
|
||||
text_lower = text.lower().strip()
|
||||
# Список префиксов команд перевода и соответствующих направлений языков
|
||||
commands = [
|
||||
("переведи на английский", "ru", "en"),
|
||||
("переведи на русский", "en", "ru"),
|
||||
("переведи с английского", "en", "ru"),
|
||||
("переведи с русского", "ru", "en"),
|
||||
("как по-английски", "ru", "en"),
|
||||
("как по английски", "ru", "en"),
|
||||
("как по-русски", "en", "ru"),
|
||||
("как по русски", "en", "ru"),
|
||||
("translate to english", "ru", "en"),
|
||||
("translate into english", "ru", "en"),
|
||||
("translate to russian", "en", "ru"),
|
||||
("translate into russian", "en", "ru"),
|
||||
("translate from english", "en", "ru"),
|
||||
("translate from russian", "ru", "en"),
|
||||
]
|
||||
|
||||
for prefix, source_lang, target_lang in commands:
|
||||
if text_lower.startswith(prefix):
|
||||
# Отрезаем команду (префикс), оставляем только текст для перевода
|
||||
rest = text[len(prefix) :].strip()
|
||||
return {
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"text": rest,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def is_stop_command(text: str) -> bool:
|
||||
"""
|
||||
Проверяет, содержится ли в тексте команда остановки.
|
||||
Удаляет знаки препинания и ищет слова из списка STOP_WORDS.
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
for ch in ",.!?:;":
|
||||
text_lower = text_lower.replace(ch, " ")
|
||||
words = text_lower.split()
|
||||
for word in words:
|
||||
if word in STOP_WORDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Основная функция (точка входа).
|
||||
"""
|
||||
print("=" * 50)
|
||||
print("🔊 УМНАЯ КОЛОНКА")
|
||||
print("=" * 50)
|
||||
print("Скажите 'Alexandr' для активации")
|
||||
print("Нажмите Ctrl+C для выхода")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Устанавливаем перехватчик Ctrl+C
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Предварительная инициализация моделей (занимает пару секунд при старте)
|
||||
print("⏳ Инициализация моделей...")
|
||||
get_recognizer().initialize() # Подключение к Deepgram
|
||||
init_tts() # Загрузка нейросети для синтеза речи (Silero)
|
||||
alarm_clock = get_alarm_clock() # Загрузка будильников
|
||||
print()
|
||||
|
||||
# История чата (храним последние 10 обменов репликами для контекста)
|
||||
chat_history = deque(maxlen=20)
|
||||
|
||||
# Переменная для хранения последнего ответа ассистента
|
||||
last_response = None
|
||||
|
||||
# Переменная, указывающая, нужно ли пропускать ожидание wake word
|
||||
# (True = режим диалога, слушаем сразу. False = ждем "Alexandr")
|
||||
skip_wakeword = False
|
||||
|
||||
# БЕСКОНЕЧНЫЙ ЦИКЛ РАБОТЫ
|
||||
while True:
|
||||
try:
|
||||
# Гарантируем, что микрофон детектора wake word освобожден
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# --- Проверка будильников ---
|
||||
# Проверяем каждую итерацию. Если будильник сработал, он заблокирует выполнение, пока его не выключат.
|
||||
if alarm_clock.check_alarms():
|
||||
# Если будильник прозвенел и был выключен пользователем, сбрасываем режим диалога
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Шаг 1: Активация ---
|
||||
if not skip_wakeword:
|
||||
# Ожидание фразы "Alexandr". Используем таймаут 1 сек, чтобы часто проверять будильники.
|
||||
detected = wait_for_wakeword(timeout=1.0)
|
||||
|
||||
# Если время вышло, а фразы не было — начинаем цикл заново (проверяем будильники)
|
||||
if not detected:
|
||||
continue
|
||||
|
||||
# Фраза услышана! Слушаем команду пользователя (7 секунд тишины макс)
|
||||
user_text = listen(timeout_seconds=7.0)
|
||||
else:
|
||||
# Режим диалога (Follow-up): ждем продолжения речи без "Alexandr"
|
||||
print("👂 Слушаю продолжение диалога (5 сек)...")
|
||||
# Ждем начала речи 5 сек. Если начали говорить, слушаем до 10 сек.
|
||||
user_text = listen(timeout_seconds=10.0, detection_timeout=5.0)
|
||||
|
||||
if not user_text:
|
||||
# Пользователь промолчал — выходим из режима диалога, засыпаем.
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# --- Шаг 2: Анализ распознанного текста ---
|
||||
if not user_text:
|
||||
# Была активация, но речь не распознана
|
||||
speak("Извините, я вас не расслышал. Попробуйте ещё раз.")
|
||||
skip_wakeword = False # Возвращаемся в режим ожидания имени
|
||||
continue
|
||||
|
||||
# Проверка на команду "Стоп"
|
||||
if is_stop_command(user_text):
|
||||
print("_" * 50)
|
||||
print("💤 Жду 'Alexandr' для активации...")
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Проверка на команду "Повтори" / "Еще раз"
|
||||
user_text_lower = user_text.lower().strip()
|
||||
repeat_phrases = [
|
||||
"еще раз",
|
||||
"повтори",
|
||||
"скажи еще раз",
|
||||
"что ты сказал",
|
||||
"повтори пожалуйста",
|
||||
"александр еще раз",
|
||||
"еще раз александр",
|
||||
"александр повтори",
|
||||
"повтори александр",
|
||||
]
|
||||
# Проверяем точное совпадение или если фраза начинается с "повтори" (но не "повтори за мной")
|
||||
if user_text_lower in repeat_phrases or (
|
||||
user_text_lower.startswith("повтори") and "за мной" not in user_text_lower
|
||||
):
|
||||
if last_response:
|
||||
print(f"🔁 Повторяю: {last_response}")
|
||||
speak(last_response)
|
||||
else:
|
||||
speak("Я еще ничего не говорил.")
|
||||
# После повтора остаемся в диалоге
|
||||
skip_wakeword = True
|
||||
continue
|
||||
|
||||
# Проверка команд будильника ("поставь будильник на 7")
|
||||
alarm_response = alarm_clock.parse_command(user_text)
|
||||
if alarm_response:
|
||||
speak(alarm_response)
|
||||
last_response = alarm_response
|
||||
continue
|
||||
|
||||
# Проверка команды громкости ("громкость 5")
|
||||
if user_text.lower().startswith("громкость"):
|
||||
try:
|
||||
# Убираем слово "громкость" и ищем число
|
||||
vol_str = user_text.lower().replace("громкость", "", 1).strip()
|
||||
level = parse_volume_text(vol_str)
|
||||
|
||||
if level is not None:
|
||||
if set_volume(level):
|
||||
msg = f"Громкость установлена на {level}"
|
||||
speak(msg)
|
||||
last_response = msg
|
||||
else:
|
||||
speak("Не удалось установить громкость.")
|
||||
else:
|
||||
speak(
|
||||
"Я не понял число громкости. Скажите число от одного до десяти."
|
||||
)
|
||||
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка громкости: {e}")
|
||||
speak("Не удалось изменить громкость.")
|
||||
continue
|
||||
|
||||
# Проверка запроса на перевод
|
||||
translation_request = parse_translation_request(user_text)
|
||||
if translation_request:
|
||||
source_lang = translation_request["source_lang"]
|
||||
target_lang = translation_request["target_lang"]
|
||||
text_to_translate = translation_request["text"]
|
||||
|
||||
# Если сказано только "переведи на английский", спрашиваем "что перевести?"
|
||||
if not text_to_translate:
|
||||
prompt = (
|
||||
"Скажи фразу на английском."
|
||||
if source_lang == "en"
|
||||
else "Скажи фразу на русском."
|
||||
)
|
||||
speak(prompt)
|
||||
# Слушаем саму фразу на нужном языке
|
||||
text_to_translate = listen(
|
||||
timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang
|
||||
)
|
||||
|
||||
if not text_to_translate:
|
||||
speak("Я не расслышал текст для перевода.")
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Выполняем перевод через AI
|
||||
translated_text = translate_text(
|
||||
text_to_translate, source_lang, target_lang
|
||||
)
|
||||
# Очищаем результат (убираем лишние символы)
|
||||
clean_text = clean_response(translated_text, language=target_lang)
|
||||
|
||||
# Сохраняем для повтора
|
||||
last_response = clean_text
|
||||
|
||||
# Озвучиваем перевод на целевом языке
|
||||
completed = speak(
|
||||
clean_text,
|
||||
check_interrupt=check_wakeword_once,
|
||||
language=target_lang,
|
||||
)
|
||||
stop_wakeword_monitoring()
|
||||
skip_wakeword = True # Остаемся в диалоге
|
||||
|
||||
if not completed:
|
||||
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
||||
continue
|
||||
|
||||
# --- Шаг 3: Запрос к AI (обычный чат) ---
|
||||
# Добавляем сообщение пользователя в историю
|
||||
chat_history.append({"role": "user", "content": user_text})
|
||||
|
||||
# Отправляем историю диалога в Perplexity
|
||||
ai_response = ask_ai(list(chat_history))
|
||||
|
||||
# Добавляем ответ AI в историю
|
||||
chat_history.append({"role": "assistant", "content": ai_response})
|
||||
|
||||
# --- Шаг 4: Очистка ответа ---
|
||||
# Убираем Markdown (**жирный**, *курсив*) и готовим числа для озвучки
|
||||
clean_text = clean_response(ai_response, language="ru")
|
||||
|
||||
# Сохраняем последний ответ для функции "еще раз"
|
||||
last_response = clean_text
|
||||
|
||||
# --- Шаг 5: Озвучка ответа ---
|
||||
# check_interrupt=check_wakeword_once позволяет прервать речь, сказав "Alexandr"
|
||||
completed = speak(
|
||||
clean_text, check_interrupt=check_wakeword_once, language="ru"
|
||||
)
|
||||
|
||||
# После озвучки обязательно закрываем поток микрофона, который открывался для проверки прерывания
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# Включаем режим диалога (следующий запрос можно говорить без имени)
|
||||
skip_wakeword = True
|
||||
|
||||
if not completed:
|
||||
print("⏹️ Ответ прерван - слушаю следующий вопрос")
|
||||
# Если перебили, значит есть новый вопрос, сразу слушаем его (цикл перезапустится)
|
||||
pass
|
||||
|
||||
print()
|
||||
print("-" * 30)
|
||||
print()
|
||||
|
||||
# --- Шаг 6: Конец итерации, возврат в начало цикла ---
|
||||
|
||||
except KeyboardInterrupt:
|
||||
signal_handler(None, None)
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка: {e}")
|
||||
speak("Произошла ошибка. Попробуйте ещё раз.")
|
||||
skip_wakeword = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
44
config.py
44
config.py
@@ -1,44 +0,0 @@
|
||||
"""
|
||||
Configuration module for smart speaker.
|
||||
Loads environment variables from .env file.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Base paths
|
||||
BASE_DIR = Path(__file__).parent
|
||||
|
||||
# Perplexity API configuration
|
||||
PERPLEXITY_API_KEY = os.getenv("PERPLEXITY_API_KEY")
|
||||
PERPLEXITY_MODEL = os.getenv("PERPLEXITY_MODEL", "llama-3.1-sonar-small-128k-chat")
|
||||
PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"
|
||||
|
||||
# Deepgram configuration
|
||||
DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
|
||||
|
||||
# Porcupine configuration
|
||||
PORCUPINE_ACCESS_KEY = os.getenv("PORCUPINE_ACCESS_KEY")
|
||||
PORCUPINE_KEYWORD_PATH = BASE_DIR / "Alexandr_en_linux_v4_0_0.ppn"
|
||||
|
||||
# Vosk configuration
|
||||
VOSK_MODEL_PATH = BASE_DIR / "vosk-model-ru-0.42"
|
||||
|
||||
# Audio configuration
|
||||
SAMPLE_RATE = 16000
|
||||
CHANNELS = 1
|
||||
|
||||
# Set timezone to Moscow
|
||||
import time
|
||||
|
||||
os.environ["TZ"] = "Europe/Moscow"
|
||||
time.tzset()
|
||||
|
||||
# TTS configuration
|
||||
TTS_SPEAKER = "eugene" # Available (ru): aidar, baya, kseniya, xenia, eugene
|
||||
TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0")
|
||||
TTS_SAMPLE_RATE = 48000
|
||||
27
data/alarms.json
Normal file
27
data/alarms.json
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"hour": 10,
|
||||
"minute": 15,
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"hour": 3,
|
||||
"minute": 42,
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"hour": 7,
|
||||
"minute": 30,
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"hour": 8,
|
||||
"minute": 15,
|
||||
"active": false
|
||||
},
|
||||
{
|
||||
"hour": 1,
|
||||
"minute": 19,
|
||||
"active": false
|
||||
}
|
||||
]
|
||||
290
main.py
290
main.py
@@ -1,290 +0,0 @@
|
||||
"""
|
||||
Smart Speaker - Main Application
|
||||
Голосовой ассистент с wake word detection, STT, AI и TTS.
|
||||
|
||||
Flow:
|
||||
1. Wait for wake word ("Alexandr")
|
||||
2. Listen to user speech (STT)
|
||||
3. Send query to AI (Perplexity)
|
||||
4. Clean response from markdown
|
||||
5. Speak response (TTS)
|
||||
6. Loop back to step 1
|
||||
"""
|
||||
|
||||
import signal
|
||||
import sys
|
||||
import re
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
from wakeword import (
|
||||
wait_for_wakeword,
|
||||
cleanup as cleanup_wakeword,
|
||||
check_wakeword_once,
|
||||
stop_monitoring as stop_wakeword_monitoring,
|
||||
)
|
||||
from stt import listen, cleanup as cleanup_stt, get_recognizer
|
||||
from ai import ask_ai, translate_text
|
||||
from cleaner import clean_response
|
||||
from tts import speak, initialize as init_tts
|
||||
from sound_level import set_volume, parse_volume_text
|
||||
from alarm import get_alarm_clock
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""Handle Ctrl+C gracefully."""
|
||||
print("\n\n👋 Завершение работы...")
|
||||
cleanup_wakeword()
|
||||
cleanup_stt()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def parse_translation_request(text: str):
|
||||
"""
|
||||
Detect translation commands and extract language direction and text.
|
||||
|
||||
Returns:
|
||||
dict with source_lang, target_lang, text or None
|
||||
"""
|
||||
patterns = [
|
||||
(r"^переведи на английский\s*(.*)$", "ru", "en"),
|
||||
(r"^переведи на русский\s*(.*)$", "en", "ru"),
|
||||
(r"^переведи с английского\s*(.*)$", "en", "ru"),
|
||||
(r"^переведи с русского\s*(.*)$", "ru", "en"),
|
||||
(r"^как по[-\s]?английски\s*(.*)$", "ru", "en"),
|
||||
(r"^как по[-\s]?русски\s*(.*)$", "en", "ru"),
|
||||
(r"^translate (?:to|into) english\s*(.*)$", "ru", "en"),
|
||||
(r"^translate (?:to|into) russian\s*(.*)$", "en", "ru"),
|
||||
(r"^translate from english\s*(.*)$", "en", "ru"),
|
||||
(r"^translate from russian\s*(.*)$", "ru", "en"),
|
||||
]
|
||||
|
||||
for pattern, source_lang, target_lang in patterns:
|
||||
match = re.match(pattern, text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
return {
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"text": match.group(1).strip(),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main application loop."""
|
||||
print("=" * 50)
|
||||
print("🔊 УМНАЯ КОЛОНКА")
|
||||
print("=" * 50)
|
||||
print("Скажите 'Alexandr' для активации")
|
||||
print("Нажмите Ctrl+C для выхода")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Setup signal handler for graceful exit
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Pre-initialize models (takes a few seconds)
|
||||
print("⏳ Инициализация моделей...")
|
||||
init_errors = []
|
||||
|
||||
def init_stt():
|
||||
try:
|
||||
get_recognizer().initialize()
|
||||
except Exception as e:
|
||||
init_errors.append(e)
|
||||
|
||||
def init_tts_model():
|
||||
try:
|
||||
init_tts()
|
||||
except Exception as e:
|
||||
init_errors.append(e)
|
||||
|
||||
stt_thread = threading.Thread(target=init_stt, daemon=True)
|
||||
tts_thread = threading.Thread(target=init_tts_model, daemon=True)
|
||||
stt_thread.start()
|
||||
tts_thread.start()
|
||||
stt_thread.join()
|
||||
tts_thread.join()
|
||||
|
||||
if init_errors:
|
||||
raise init_errors[0]
|
||||
|
||||
alarm_clock = get_alarm_clock() # Initialize Alarm Clock
|
||||
print()
|
||||
|
||||
# Initialize chat history (last 10 exchanges = 20 messages)
|
||||
chat_history = deque(maxlen=20)
|
||||
|
||||
# Main loop
|
||||
skip_wakeword = False
|
||||
while True:
|
||||
try:
|
||||
# Ensure wake word detector stream is closed before listening
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# Check for alarms every loop iteration
|
||||
if alarm_clock.check_alarms():
|
||||
# If alarm triggered and finished (user stopped it), we continue loop
|
||||
# The alarm.trigger_alarm() blocks until stopped.
|
||||
skip_wakeword = False # Reset state after alarm
|
||||
continue
|
||||
|
||||
# Step 1: Wait for wake word or Follow-up listen
|
||||
if not skip_wakeword:
|
||||
# Wait with timeout to allow alarm checking
|
||||
detected = wait_for_wakeword(timeout=1.0)
|
||||
|
||||
# If timeout (not detected), loop again to check alarms
|
||||
if not detected:
|
||||
continue
|
||||
|
||||
# Standard listen after activation
|
||||
user_text = listen(timeout_seconds=7.0)
|
||||
else:
|
||||
# Follow-up listen (wait 5.0s for start)
|
||||
print("👂 Слушаю продолжение диалога (5 сек)...")
|
||||
user_text = listen(timeout_seconds=10.0, detection_timeout=5.0)
|
||||
|
||||
if not user_text:
|
||||
# User didn't continue conversation, go back to sleep silently
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Step 2: Check if speech was recognized
|
||||
if not user_text:
|
||||
# If this was a direct wake word activation but no speech
|
||||
speak("Извините, я вас не расслышал. Попробуйте ещё раз.")
|
||||
skip_wakeword = False # Reset to wake word
|
||||
continue
|
||||
|
||||
# Check for stop commands
|
||||
user_text_lower = user_text.lower().strip()
|
||||
if user_text_lower in ["стоп", "александр", "стоп александр", "хватит"]:
|
||||
print("_" * 50)
|
||||
print("💤 Жду 'Alexandr' для активации...")
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
# Check for alarm commands
|
||||
alarm_response = alarm_clock.parse_command(user_text)
|
||||
if alarm_response:
|
||||
speak(alarm_response)
|
||||
continue
|
||||
|
||||
# Check for volume command
|
||||
if user_text.lower().startswith("громкость"):
|
||||
try:
|
||||
# Remove "громкость" prefix and strip whitespace
|
||||
vol_str = user_text.lower().replace("громкость", "", 1).strip()
|
||||
|
||||
# Try to parse the number
|
||||
level = parse_volume_text(vol_str)
|
||||
|
||||
if level is not None:
|
||||
if set_volume(level):
|
||||
speak(f"Громкость установлена на {level}")
|
||||
else:
|
||||
speak("Не удалось установить громкость.")
|
||||
else:
|
||||
speak(
|
||||
"Я не понял число громкости. Скажите число от одного до десяти."
|
||||
)
|
||||
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка громкости: {e}")
|
||||
speak("Не удалось изменить громкость.")
|
||||
continue
|
||||
|
||||
# Check for translation commands
|
||||
translation_request = parse_translation_request(user_text)
|
||||
if translation_request:
|
||||
source_lang = translation_request["source_lang"]
|
||||
target_lang = translation_request["target_lang"]
|
||||
text_to_translate = translation_request["text"]
|
||||
|
||||
if not text_to_translate:
|
||||
prompt = (
|
||||
"Скажи фразу на английском."
|
||||
if source_lang == "en"
|
||||
else "Скажи фразу на русском."
|
||||
)
|
||||
speak(prompt)
|
||||
text_to_translate = listen(
|
||||
timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang
|
||||
)
|
||||
|
||||
if not text_to_translate:
|
||||
speak("Я не расслышал текст для перевода.")
|
||||
skip_wakeword = False
|
||||
continue
|
||||
|
||||
translated_text = translate_text(
|
||||
text_to_translate, source_lang, target_lang
|
||||
)
|
||||
clean_text = clean_response(translated_text, language=target_lang)
|
||||
|
||||
completed = speak(
|
||||
clean_text,
|
||||
check_interrupt=check_wakeword_once,
|
||||
language=target_lang,
|
||||
)
|
||||
stop_wakeword_monitoring()
|
||||
skip_wakeword = True
|
||||
|
||||
if not completed:
|
||||
print("⏹️ Перевод прерван - слушаю следующий вопрос")
|
||||
continue
|
||||
|
||||
# Step 3: Send to AI
|
||||
# Add user message to history
|
||||
chat_history.append({"role": "user", "content": user_text})
|
||||
|
||||
# Get response using history
|
||||
ai_response = ask_ai(list(chat_history))
|
||||
|
||||
# Add AI response to history
|
||||
chat_history.append({"role": "assistant", "content": ai_response})
|
||||
|
||||
# Step 4: Clean response
|
||||
clean_text = clean_response(ai_response, language="ru")
|
||||
|
||||
# Step 5: Speak response (with wake word interrupt support)
|
||||
# This uses check_wakeword_once which opens/closes stream as needed
|
||||
completed = speak(
|
||||
clean_text, check_interrupt=check_wakeword_once, language="ru"
|
||||
)
|
||||
|
||||
# Stop monitoring after TTS finishes (cleanup stream opened by check_wakeword_once)
|
||||
stop_wakeword_monitoring()
|
||||
|
||||
# Enable follow-up mode for next iteration
|
||||
skip_wakeword = True
|
||||
|
||||
# If interrupted by wake word, we still want to skip_wakeword (which is set above)
|
||||
# but we can print a message
|
||||
if not completed:
|
||||
print("⏹️ Ответ прерван - слушаю следующий вопрос")
|
||||
# If interrupted, we treat it as immediate follow up?
|
||||
# Usually interruption means "I have a new command"
|
||||
# So skip_wakeword = True is correct.
|
||||
# But we might want to listen IMMEDIATELY without waiting 5s for start?
|
||||
# listen() handles that.
|
||||
pass
|
||||
|
||||
print()
|
||||
print("-" * 30)
|
||||
print()
|
||||
|
||||
# Step 6: Loop continues with skip_wakeword=True
|
||||
|
||||
except KeyboardInterrupt:
|
||||
signal_handler(None, None)
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка: {e}")
|
||||
speak("Произошла ошибка. Попробуйте ещё раз.")
|
||||
skip_wakeword = False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
run.py
Normal file
9
run.py
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -1,70 +0,0 @@
|
||||
"""
|
||||
Volume control module.
|
||||
Regulates system volume on a scale from 1 to 10.
|
||||
"""
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
NUMBER_MAP = {
|
||||
"один": 1, "раз": 1, "два": 2, "три": 3, "четыре": 4,
|
||||
"пять": 5, "шесть": 6, "семь": 7, "восемь": 8, "девять": 9, "десять": 10
|
||||
}
|
||||
|
||||
|
||||
def set_volume(level: int) -> bool:
|
||||
"""
|
||||
Set system volume (1-10 corresponding to 10%-100%).
|
||||
|
||||
Args:
|
||||
level: Integer between 1 and 10
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not isinstance(level, int):
|
||||
print(f"❌ Ошибка: Уровень громкости должен быть целым числом, получено {type(level)}")
|
||||
return False
|
||||
|
||||
if level < 1:
|
||||
level = 1
|
||||
elif level > 10:
|
||||
level = 10
|
||||
|
||||
percentage = level * 10
|
||||
|
||||
try:
|
||||
# Set volume using amixer
|
||||
# -q: quiet
|
||||
# sset: set simple control
|
||||
# Master: control name
|
||||
# %: percentage
|
||||
cmd = ["amixer", "-q", "sset", "Master", f"{percentage}%"]
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"🔊 Громкость установлена на {level} ({percentage}%)")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ Ошибка при установке громкости: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Неизвестная ошибка громкости: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def parse_volume_text(text: str) -> int | None:
|
||||
"""
|
||||
Parse volume level from text (digits or Russian words).
|
||||
Returns integer 1-10 or None if not found.
|
||||
"""
|
||||
text = text.lower()
|
||||
|
||||
# 1. Check for digits
|
||||
num_match = re.search(r'\b(10|[1-9])\b', text)
|
||||
if num_match:
|
||||
return int(num_match.group())
|
||||
|
||||
# 2. Check for words
|
||||
for word, value in NUMBER_MAP.items():
|
||||
if word in text:
|
||||
return value
|
||||
|
||||
return None
|
||||
237
stt.py
237
stt.py
@@ -1,237 +0,0 @@
|
||||
"""
|
||||
Speech-to-Text module using Deepgram API.
|
||||
Recognizes speech from microphone using streaming WebSocket.
|
||||
Supports Russian (default) and English.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import threading
|
||||
import pyaudio
|
||||
import logging
|
||||
from config import DEEPGRAM_API_KEY, SAMPLE_RATE
|
||||
from deepgram import (
|
||||
DeepgramClient,
|
||||
DeepgramClientOptions,
|
||||
LiveTranscriptionEvents,
|
||||
LiveOptions,
|
||||
Microphone,
|
||||
)
|
||||
|
||||
# Configure logging to suppress debug noise
|
||||
logging.getLogger("deepgram").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class SpeechRecognizer:
|
||||
"""Speech recognizer using Deepgram streaming."""
|
||||
|
||||
def __init__(self):
|
||||
self.dg_client = None
|
||||
self.pa = None
|
||||
self.stream = None
|
||||
self.transcript = ""
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize Deepgram client and PyAudio."""
|
||||
if not DEEPGRAM_API_KEY:
|
||||
raise ValueError("DEEPGRAM_API_KEY is not set in environment or config.")
|
||||
|
||||
print("📦 Инициализация Deepgram STT...")
|
||||
config = DeepgramClientOptions(
|
||||
verbose=logging.WARNING,
|
||||
)
|
||||
self.dg_client = DeepgramClient(DEEPGRAM_API_KEY, config)
|
||||
|
||||
self.pa = pyaudio.PyAudio()
|
||||
print("✅ Deepgram клиент готов")
|
||||
|
||||
def _get_stream(self):
|
||||
"""Open audio stream if not open."""
|
||||
if self.stream is None:
|
||||
self.stream = self.pa.open(
|
||||
rate=SAMPLE_RATE,
|
||||
channels=1,
|
||||
format=pyaudio.paInt16,
|
||||
input=True,
|
||||
frames_per_buffer=4096,
|
||||
)
|
||||
return self.stream
|
||||
|
||||
async def _process_audio(self, dg_connection, timeout_seconds, detection_timeout):
|
||||
"""Async loop to send audio and wait for results."""
|
||||
self.transcript = ""
|
||||
transcript_parts = []
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
stream = self._get_stream()
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
speech_started_event = asyncio.Event()
|
||||
|
||||
# We need access to the outer 'self' (SpeechRecognizer instance)
|
||||
speech_recognizer_self = self
|
||||
|
||||
def on_transcript(unused_self, result, **kwargs):
|
||||
sentence = result.channel.alternatives[0].transcript
|
||||
if len(sentence) == 0:
|
||||
return
|
||||
if result.is_final:
|
||||
with speech_recognizer_self.lock:
|
||||
transcript_parts.append(sentence)
|
||||
speech_recognizer_self.transcript = " ".join(
|
||||
transcript_parts
|
||||
).strip()
|
||||
|
||||
def on_speech_started(unused_self, speech_started, **kwargs):
|
||||
loop.call_soon_threadsafe(speech_started_event.set)
|
||||
|
||||
def on_utterance_end(unused_self, utterance_end, **kwargs):
|
||||
loop.call_soon_threadsafe(stop_event.set)
|
||||
|
||||
def on_error(unused_self, error, **kwargs):
|
||||
print(f"Error: {error}")
|
||||
loop.call_soon_threadsafe(stop_event.set)
|
||||
|
||||
dg_connection.on(LiveTranscriptionEvents.Transcript, on_transcript)
|
||||
dg_connection.on(LiveTranscriptionEvents.SpeechStarted, on_speech_started)
|
||||
dg_connection.on(LiveTranscriptionEvents.UtteranceEnd, on_utterance_end)
|
||||
dg_connection.on(LiveTranscriptionEvents.Error, on_error)
|
||||
|
||||
# Start connection (Synchronous call, NO await)
|
||||
options = LiveOptions(
|
||||
model="nova-2",
|
||||
language=self.current_lang,
|
||||
smart_format=True,
|
||||
encoding="linear16",
|
||||
channels=1,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
interim_results=True,
|
||||
utterance_end_ms=1200,
|
||||
vad_events=True,
|
||||
)
|
||||
|
||||
if dg_connection.start(options) is False:
|
||||
print("Failed to start Deepgram connection")
|
||||
return
|
||||
|
||||
# Audio sending loop
|
||||
async def send_audio():
|
||||
chunks_sent = 0
|
||||
try:
|
||||
stream.start_stream()
|
||||
print("🎤 Stream started, sending audio...")
|
||||
while not stop_event.is_set():
|
||||
if stream.is_active():
|
||||
data = stream.read(4096, exception_on_overflow=False)
|
||||
# Send is synchronous in Sync client, NO await
|
||||
dg_connection.send(data)
|
||||
chunks_sent += 1
|
||||
if chunks_sent % 50 == 0:
|
||||
print(f".", end="", flush=True)
|
||||
# Yield to allow event loop to process events (timeouts etc)
|
||||
await asyncio.sleep(0.005)
|
||||
except Exception as e:
|
||||
print(f"Audio send error: {e}")
|
||||
finally:
|
||||
stream.stop_stream()
|
||||
print(f"\n🛑 Stream stopped. Chunks sent: {chunks_sent}")
|
||||
|
||||
sender_task = asyncio.create_task(send_audio())
|
||||
|
||||
try:
|
||||
# 1. Wait for speech to start (detection_timeout)
|
||||
if detection_timeout:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
speech_started_event.wait(), timeout=detection_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# print("Detection timeout - no speech")
|
||||
stop_event.set()
|
||||
|
||||
# 2. If started (or no detection timeout), wait for completion
|
||||
if not stop_event.is_set():
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=timeout_seconds)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# print("Global timeout")
|
||||
pass
|
||||
|
||||
stop_event.set()
|
||||
await sender_task
|
||||
# Finish is synchronous
|
||||
dg_connection.finish()
|
||||
|
||||
return self.transcript
|
||||
|
||||
def listen(
|
||||
self,
|
||||
timeout_seconds: float = 7.0,
|
||||
detection_timeout: float = None,
|
||||
lang: str = "ru",
|
||||
) -> str:
|
||||
"""
|
||||
Listen to microphone and transcribe speech.
|
||||
"""
|
||||
if not self.dg_client:
|
||||
self.initialize()
|
||||
|
||||
self.current_lang = lang
|
||||
print(f"🎙️ Слушаю ({lang})...")
|
||||
|
||||
# Create a new connection for each listen session
|
||||
dg_connection = self.dg_client.listen.live.v("1")
|
||||
|
||||
try:
|
||||
transcript = asyncio.run(
|
||||
self._process_audio(dg_connection, timeout_seconds, detection_timeout)
|
||||
)
|
||||
|
||||
final_text = transcript.strip() if transcript else ""
|
||||
if final_text:
|
||||
print(f"📝 Распознано: {final_text}")
|
||||
else:
|
||||
print("⚠️ Речь не распознана")
|
||||
|
||||
return final_text
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка STT: {e}")
|
||||
return ""
|
||||
|
||||
def cleanup(self):
|
||||
"""Release resources."""
|
||||
if self.stream:
|
||||
self.stream.stop_stream()
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
if self.pa:
|
||||
self.pa.terminate()
|
||||
|
||||
|
||||
# Global instance
|
||||
_recognizer = None
|
||||
|
||||
|
||||
def get_recognizer() -> SpeechRecognizer:
|
||||
"""Get or create speech recognizer instance."""
|
||||
global _recognizer
|
||||
if _recognizer is None:
|
||||
_recognizer = SpeechRecognizer()
|
||||
return _recognizer
|
||||
|
||||
|
||||
def listen(
|
||||
timeout_seconds: float = 7.0, detection_timeout: float = None, lang: str = "ru"
|
||||
) -> str:
|
||||
"""Listen to microphone and return transcribed text."""
|
||||
return get_recognizer().listen(timeout_seconds, detection_timeout, lang)
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""Cleanup recognizer resources."""
|
||||
global _recognizer
|
||||
if _recognizer:
|
||||
_recognizer.cleanup()
|
||||
_recognizer = None
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import cleaner
|
||||
# Простые проверки для модуля cleaner (запуск вручную).
|
||||
from app.core import cleaner
|
||||
import traceback
|
||||
|
||||
try:
|
||||
@@ -7,11 +7,11 @@ try:
|
||||
text = "В 1999 году."
|
||||
res = cleaner.clean_response(text)
|
||||
print(f"Result: {res}")
|
||||
|
||||
|
||||
text = ""
|
||||
res = cleaner.clean_response(text)
|
||||
print(f"Result: {res}")
|
||||
|
||||
|
||||
text = "[link](http://example.com)"
|
||||
res = cleaner.clean_response(text)
|
||||
print(f"Result: {res}")
|
||||
272
tts.py
272
tts.py
@@ -1,272 +0,0 @@
|
||||
"""
|
||||
Text-to-Speech module using Silero TTS.
|
||||
Generates natural Russian speech.
|
||||
Supports interruption via wake word detection using threading.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
import re
|
||||
from config import TTS_SPEAKER, TTS_EN_SPEAKER, TTS_SAMPLE_RATE
|
||||
|
||||
# Suppress Silero TTS warning about text length
|
||||
warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols")
|
||||
|
||||
|
||||
class TextToSpeech:
|
||||
"""Text-to-Speech using Silero TTS with wake word interruption support."""
|
||||
|
||||
def __init__(self):
|
||||
self.models = {}
|
||||
self.sample_rate = TTS_SAMPLE_RATE
|
||||
self.speakers = {
|
||||
"ru": TTS_SPEAKER,
|
||||
"en": TTS_EN_SPEAKER,
|
||||
}
|
||||
self._interrupted = False
|
||||
self._stop_flag = threading.Event()
|
||||
|
||||
def _load_model(self, language: str):
|
||||
"""Load and cache Silero TTS model for the given language."""
|
||||
if language in self.models:
|
||||
return self.models[language]
|
||||
|
||||
model_config = {
|
||||
"ru": {"language": "ru", "model_id": "v5_ru"},
|
||||
"en": {"language": "en", "model_id": "v3_en"},
|
||||
}
|
||||
|
||||
if language not in model_config:
|
||||
raise ValueError(f"Unsupported TTS language: {language}")
|
||||
|
||||
config = model_config[language]
|
||||
print(f"📦 Загрузка модели Silero TTS ({language})...")
|
||||
|
||||
device = torch.device("cpu")
|
||||
model, _ = torch.hub.load(
|
||||
repo_or_dir="snakers4/silero-models",
|
||||
model="silero_tts",
|
||||
language=config["language"],
|
||||
speaker=config["model_id"],
|
||||
)
|
||||
model.to(device)
|
||||
|
||||
self.models[language] = model
|
||||
return model
|
||||
|
||||
def _get_speaker(self, language: str, model) -> str:
|
||||
"""Return a valid speaker for the loaded model."""
|
||||
speaker = self.speakers.get(language)
|
||||
if hasattr(model, "speakers") and speaker not in model.speakers:
|
||||
fallback = model.speakers[0] if model.speakers else speaker
|
||||
print(f"⚠️ Голос '{speaker}' недоступен, использую '{fallback}'")
|
||||
return fallback
|
||||
return speaker
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize default (Russian) TTS model."""
|
||||
self._load_model("ru")
|
||||
|
||||
def _split_text(self, text: str, max_length: int = 900) -> list[str]:
|
||||
"""Split text into chunks smaller than max_length."""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
# Split by sentence endings, keeping the punctuation
|
||||
# pattern matches [.!?] followed by optional newlines
|
||||
parts = re.split(r"([.!?]+\s*)", text)
|
||||
|
||||
current_chunk = ""
|
||||
# Reconstruct sentences. re.split with groups returns [text, delimiter, text, delimiter...]
|
||||
# We iterate through parts. If part is a delimiter (matches pattern), we append to previous text.
|
||||
|
||||
for part in parts:
|
||||
# If the part combined with current_chunk exceeds max_length, save current_chunk
|
||||
if len(current_chunk) + len(part) > max_length:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = ""
|
||||
|
||||
current_chunk += part
|
||||
|
||||
# If even a single part is too big (very long sentence without punctuation), force split
|
||||
while len(current_chunk) > max_length:
|
||||
# Try to split by space
|
||||
split_idx = current_chunk.rfind(" ", 0, max_length)
|
||||
if split_idx == -1:
|
||||
# No space found, hard cut
|
||||
split_idx = max_length
|
||||
|
||||
chunks.append(current_chunk[:split_idx].strip())
|
||||
current_chunk = current_chunk[split_idx:].lstrip()
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
# Filter empty chunks
|
||||
return [c for c in chunks if c]
|
||||
|
||||
def speak(self, text: str, check_interrupt=None, language: str = "ru") -> bool:
|
||||
"""
|
||||
Convert text to speech and play it.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize and speak
|
||||
check_interrupt: Optional callback function that returns True if playback should stop
|
||||
language: Language code for voice selection ("ru" or "en")
|
||||
|
||||
Returns:
|
||||
True if playback completed normally, False if interrupted
|
||||
"""
|
||||
if not text.strip():
|
||||
return True
|
||||
|
||||
model = self._load_model(language)
|
||||
speaker = self._get_speaker(language, model)
|
||||
|
||||
# Split text into manageable chunks
|
||||
chunks = self._split_text(text)
|
||||
total_chunks = len(chunks)
|
||||
|
||||
if total_chunks > 1:
|
||||
print(f"🔊 Озвучивание (частей: {total_chunks}): {text[:50]}...")
|
||||
else:
|
||||
print(f"🔊 Озвучивание: {text[:50]}...")
|
||||
|
||||
self._interrupted = False
|
||||
self._stop_flag.clear()
|
||||
|
||||
success = True
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
if self._interrupted:
|
||||
break
|
||||
|
||||
try:
|
||||
# Generate audio for chunk
|
||||
audio = model.apply_tts(
|
||||
text=chunk, speaker=speaker, sample_rate=self.sample_rate
|
||||
)
|
||||
|
||||
# Convert to numpy array
|
||||
audio_np = audio.numpy()
|
||||
|
||||
if check_interrupt:
|
||||
# Play with interrupt checking in parallel thread
|
||||
if not self._play_with_interrupt(audio_np, check_interrupt):
|
||||
success = False
|
||||
break
|
||||
else:
|
||||
# Standard playback
|
||||
sd.play(audio_np, self.sample_rate)
|
||||
sd.wait()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка TTS (часть {i + 1}/{total_chunks}): {e}")
|
||||
success = False
|
||||
|
||||
if success and not self._interrupted:
|
||||
print("✅ Воспроизведение завершено")
|
||||
return True
|
||||
elif self._interrupted:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def _check_interrupt_worker(self, check_interrupt):
|
||||
"""
|
||||
Worker thread that continuously checks for interrupt signal.
|
||||
"""
|
||||
while not self._stop_flag.is_set():
|
||||
try:
|
||||
if check_interrupt():
|
||||
self._interrupted = True
|
||||
sd.stop()
|
||||
print("⏹️ Воспроизведение прервано!")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _play_with_interrupt(self, audio_np: np.ndarray, check_interrupt) -> bool:
|
||||
"""
|
||||
Play audio with interrupt checking in parallel thread.
|
||||
|
||||
Args:
|
||||
audio_np: Audio data as numpy array
|
||||
check_interrupt: Callback that returns True if should interrupt
|
||||
|
||||
Returns:
|
||||
True if completed normally, False if interrupted
|
||||
"""
|
||||
# Start interrupt checker thread
|
||||
checker_thread = threading.Thread(
|
||||
target=self._check_interrupt_worker, args=(check_interrupt,), daemon=True
|
||||
)
|
||||
checker_thread.start()
|
||||
|
||||
try:
|
||||
# Play audio (non-blocking start)
|
||||
sd.play(audio_np, self.sample_rate)
|
||||
|
||||
# Wait for playback to finish or interrupt
|
||||
while sd.get_stream().active:
|
||||
if self._interrupted:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
finally:
|
||||
# Signal checker thread to stop
|
||||
self._stop_flag.set()
|
||||
checker_thread.join(timeout=0.5)
|
||||
|
||||
if self._interrupted:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def was_interrupted(self) -> bool:
|
||||
"""Check if the last playback was interrupted."""
|
||||
return self._interrupted
|
||||
|
||||
|
||||
# Global instance
|
||||
_tts = None
|
||||
|
||||
|
||||
def get_tts() -> TextToSpeech:
|
||||
"""Get or create TTS instance."""
|
||||
global _tts
|
||||
if _tts is None:
|
||||
_tts = TextToSpeech()
|
||||
return _tts
|
||||
|
||||
|
||||
def speak(text: str, check_interrupt=None, language: str = "ru") -> bool:
|
||||
"""
|
||||
Synthesize and speak the given text.
|
||||
|
||||
Args:
|
||||
text: Text to speak
|
||||
check_interrupt: Optional callback for interrupt checking
|
||||
language: Language code for voice selection ("ru" or "en")
|
||||
|
||||
Returns:
|
||||
True if completed normally, False if interrupted
|
||||
"""
|
||||
return get_tts().speak(text, check_interrupt, language)
|
||||
|
||||
|
||||
def was_interrupted() -> bool:
|
||||
"""Check if the last speak() call was interrupted."""
|
||||
return get_tts().was_interrupted
|
||||
|
||||
|
||||
def initialize():
|
||||
"""Pre-initialize TTS model."""
|
||||
get_tts().initialize()
|
||||
157
wakeword.py
157
wakeword.py
@@ -1,157 +0,0 @@
|
||||
"""
|
||||
Wake word detection module using Porcupine.
|
||||
Listens for the "Alexandr" wake word.
|
||||
"""
|
||||
import pvporcupine
|
||||
import pyaudio
|
||||
import struct
|
||||
from config import PORCUPINE_ACCESS_KEY, PORCUPINE_KEYWORD_PATH
|
||||
|
||||
|
||||
class WakeWordDetector:
|
||||
"""Detects wake word using Porcupine."""
|
||||
|
||||
def __init__(self):
|
||||
self.porcupine = None
|
||||
self.audio_stream = None
|
||||
self.pa = None
|
||||
self._stream_closed = True # Track state explicitly
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize Porcupine and audio stream."""
|
||||
self.porcupine = pvporcupine.create(
|
||||
access_key=PORCUPINE_ACCESS_KEY,
|
||||
keyword_paths=[str(PORCUPINE_KEYWORD_PATH)]
|
||||
)
|
||||
|
||||
self.pa = pyaudio.PyAudio()
|
||||
self._open_stream()
|
||||
print("🎤 Ожидание wake word 'Alexandr'...")
|
||||
|
||||
def _open_stream(self):
|
||||
"""Open the audio stream."""
|
||||
if self.audio_stream and not self._stream_closed:
|
||||
return
|
||||
|
||||
if self.audio_stream:
|
||||
try:
|
||||
self.audio_stream.close()
|
||||
except: pass
|
||||
|
||||
self.audio_stream = self.pa.open(
|
||||
rate=self.porcupine.sample_rate,
|
||||
channels=1,
|
||||
format=pyaudio.paInt16,
|
||||
input=True,
|
||||
frames_per_buffer=self.porcupine.frame_length
|
||||
)
|
||||
self._stream_closed = False
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Explicitly stop and close the stream."""
|
||||
if self.audio_stream and not self._stream_closed:
|
||||
try:
|
||||
self.audio_stream.stop_stream()
|
||||
self.audio_stream.close()
|
||||
except: pass
|
||||
self._stream_closed = True
|
||||
|
||||
def wait_for_wakeword(self, timeout: float = None) -> bool:
|
||||
"""
|
||||
Blocks until wake word is detected or timeout expires.
|
||||
|
||||
Args:
|
||||
timeout: Maximum seconds to wait. None = infinite.
|
||||
|
||||
Returns:
|
||||
True if wake word detected, False if timeout.
|
||||
"""
|
||||
import time
|
||||
if not self.porcupine:
|
||||
self.initialize()
|
||||
|
||||
# Ensure stream is open
|
||||
self._open_stream()
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
if timeout and (time.time() - start_time > timeout):
|
||||
return False
|
||||
|
||||
pcm = self.audio_stream.read(self.porcupine.frame_length, exception_on_overflow=False)
|
||||
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
|
||||
|
||||
keyword_index = self.porcupine.process(pcm)
|
||||
if keyword_index >= 0:
|
||||
print("✅ Wake word обнаружен!")
|
||||
self.stop_monitoring()
|
||||
return True
|
||||
|
||||
def check_wakeword_once(self) -> bool:
|
||||
"""
|
||||
Non-blocking check for wake word.
|
||||
Returns True if wake word detected, False otherwise.
|
||||
"""
|
||||
if not self.porcupine:
|
||||
self.initialize()
|
||||
|
||||
try:
|
||||
# Ensure stream is open
|
||||
self._open_stream()
|
||||
|
||||
pcm = self.audio_stream.read(self.porcupine.frame_length, exception_on_overflow=False)
|
||||
pcm = struct.unpack_from("h" * self.porcupine.frame_length, pcm)
|
||||
|
||||
keyword_index = self.porcupine.process(pcm)
|
||||
if keyword_index >= 0:
|
||||
print("🛑 Wake word обнаружен во время ответа!")
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cleanup(self):
|
||||
"""Release resources."""
|
||||
self.stop_monitoring()
|
||||
if self.pa:
|
||||
self.pa.terminate()
|
||||
if self.porcupine:
|
||||
self.porcupine.delete()
|
||||
|
||||
|
||||
# Global instance
|
||||
_detector = None
|
||||
|
||||
|
||||
def get_detector() -> WakeWordDetector:
|
||||
"""Get or create wake word detector instance."""
|
||||
global _detector
|
||||
if _detector is None:
|
||||
_detector = WakeWordDetector()
|
||||
return _detector
|
||||
|
||||
|
||||
def wait_for_wakeword(timeout: float = None) -> bool:
|
||||
"""Wait for wake word detection."""
|
||||
return get_detector().wait_for_wakeword(timeout)
|
||||
|
||||
def stop_monitoring():
|
||||
"""Stop monitoring for wake word."""
|
||||
if _detector:
|
||||
_detector.stop_monitoring()
|
||||
|
||||
def cleanup():
|
||||
"""Cleanup detector resources."""
|
||||
global _detector
|
||||
if _detector:
|
||||
_detector.cleanup()
|
||||
_detector = None
|
||||
|
||||
|
||||
def check_wakeword_once() -> bool:
|
||||
"""
|
||||
Non-blocking check for wake word.
|
||||
Returns True if wake word detected, False otherwise.
|
||||
"""
|
||||
return get_detector().check_wakeword_once()
|
||||
Reference in New Issue
Block a user