другая структура проекта + beads + александр повтори + комментарии везде + readme

This commit is contained in:
2026-01-09 04:14:50 +03:00
parent 242ead5355
commit ce28fede74
31 changed files with 1654 additions and 1333 deletions

4
.gitignore vendored
View File

@@ -37,3 +37,7 @@ vosk-model-*/
# VS Code # VS Code
.vscode/ .vscode/
.beads
.gitattributes

40
AGENTS.md Normal file
View 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
View 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`.

View File

@@ -1,12 +0,0 @@
[
{
"hour": 10,
"minute": 15,
"active": true
},
{
"hour": 3,
"minute": 42,
"active": false
}
]

0
app/__init__.py Normal file
View File

0
app/audio/__init__.py Normal file
View File

View File

@@ -2,14 +2,23 @@
Local offline Speech-to-Text module using Vosk. Local offline Speech-to-Text module using Vosk.
Used for simple command detection (like "stop") without internet. Used for simple command detection (like "stop") without internet.
""" """
# Модуль локального распознавания речи (Vosk).
# Работает полностью оффлайн (без интернета).
# Используется, когда нужно распознать простые команды (например, "стоп" во время будильника),
# чтобы не тратить трафик и время на обращение к облаку.
import os import os
import sys import sys
import json import json
import pyaudio import pyaudio
from vosk import Model, KaldiRecognizer from vosk import Model, KaldiRecognizer
from config import VOSK_MODEL_PATH, SAMPLE_RATE from ..core.config import VOSK_MODEL_PATH, SAMPLE_RATE
class LocalRecognizer: class LocalRecognizer:
"""Класс для работы с Vosk."""
def __init__(self): def __init__(self):
self.model = None self.model = None
self.rec = None self.rec = None
@@ -17,12 +26,14 @@ class LocalRecognizer:
self.stream = None self.stream = None
def initialize(self): def initialize(self):
"""Загрузка модели Vosk."""
if not os.path.exists(VOSK_MODEL_PATH): if not os.path.exists(VOSK_MODEL_PATH):
print(f"❌ Ошибка: Vosk модель не найдена по пути {VOSK_MODEL_PATH}") print(f"❌ Ошибка: Vosk модель не найдена по пути {VOSK_MODEL_PATH}")
return False return False
print("📦 Инициализация локального STT (Vosk)...") print("📦 Инициализация локального STT (Vosk)...")
# Redirect stderr to suppress Vosk logs
# Трюк для подавления вывода логов Vosk в консоль (он очень шумный)
try: try:
null_fd = os.open(os.devnull, os.O_WRONLY) null_fd = os.open(os.devnull, os.O_WRONLY)
old_stderr = os.dup(2) old_stderr = os.dup(2)
@@ -30,9 +41,10 @@ class LocalRecognizer:
os.dup2(null_fd, 2) os.dup2(null_fd, 2)
os.close(null_fd) os.close(null_fd)
# Сама загрузка модели
self.model = Model(str(VOSK_MODEL_PATH)) self.model = Model(str(VOSK_MODEL_PATH))
# Restore stderr # Возвращаем stderr обратно
os.dup2(old_stderr, 2) os.dup2(old_stderr, 2)
os.close(old_stderr) os.close(old_stderr)
except Exception as e: except Exception as e:
@@ -45,22 +57,35 @@ class LocalRecognizer:
def listen_for_keywords(self, keywords: list, timeout: float = 10.0) -> str: 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.model:
if not self.initialize(): if not self.initialize():
return "" return ""
# Open stream # Открываем поток микрофона
try: 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() stream.start_stream()
except Exception as e: except Exception as e:
print(f"❌ Ошибка микрофона: {e}") print(f"❌ Ошибка микрофона: {e}")
return "" return ""
import time import time
start_time = time.time() start_time = time.time()
print(f"👂 Локальное слушание ожидает: {keywords}") print(f"👂 Локальное слушание ожидает: {keywords}")
@@ -70,22 +95,25 @@ class LocalRecognizer:
try: try:
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
data = stream.read(4096, exception_on_overflow=False) data = stream.read(4096, exception_on_overflow=False)
# Vosk обрабатывает аудио чанками
if self.rec.AcceptWaveform(data): if self.rec.AcceptWaveform(data):
# Полный результат
res = json.loads(self.rec.Result()) res = json.loads(self.rec.Result())
text = res.get("text", "") text = res.get("text", "")
if text: if text:
print(f"📝 Локально: {text}") print(f"📝 Локально: {text}")
# Check against keywords # Проверяем, есть ли ключевое слово в распознанном тексте
for kw in keywords: for kw in keywords:
if kw in text: if kw in text:
detected_text = text detected_text = text
break break
else: else:
# Partial result # Частичный результат (быстрее, чем полный)
res = json.loads(self.rec.PartialResult()) res = json.loads(self.rec.PartialResult())
partial = res.get("partial", "") partial = res.get("partial", "")
if partial: if partial:
for kw in keywords: for kw in keywords:
if kw in partial: if kw in partial:
detected_text = partial detected_text = partial
break break
@@ -102,15 +130,18 @@ class LocalRecognizer:
if self.pa: if self.pa:
self.pa.terminate() self.pa.terminate()
# Global instance
# Глобальный экземпляр
_local_recognizer = None _local_recognizer = None
def get_local_recognizer(): def get_local_recognizer():
global _local_recognizer global _local_recognizer
if _local_recognizer is None: if _local_recognizer is None:
_local_recognizer = LocalRecognizer() _local_recognizer = LocalRecognizer()
return _local_recognizer return _local_recognizer
def listen_for_keywords(keywords: list, timeout: float = 5.0) -> str: 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) return get_local_recognizer().listen_for_keywords(keywords, timeout)

87
app/audio/sound_level.py Normal file
View 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
View 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
View 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
View 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
View File

View File

@@ -1,13 +1,14 @@
""" """AI module for Perplexity API integration."""
AI module for Perplexity API integration.
Sends user queries and receives AI responses. # Модуль общения с искусственным интеллектом (Perplexity API).
""" # Обрабатывает запросы пользователя и переводы.
import requests 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 = """Ты — Александр, умный голосовой ассистент с человеческим поведением. SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно. Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
Твоя главная цель помогать пользователю и поддерживать интересный диалог. Твоя главная цель помогать пользователю и поддерживать интересный диалог.
@@ -16,79 +17,86 @@ SYSTEM_PROMPT = """Ты — Александр, умный голосовой а
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов. Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.""" ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные."""
# Системный промпт для режима переводчика.
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine. TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
Translate from {source} to {target}. Translate from {source} to {target}.
Return only the translated text, without quotes, comments, or explanations.""" 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: Args:
messages_history: List of dictionaries with role and content messages: Список сообщений (история чата).
e.g., [{"role": "user", "content": "Hi"}] max_tokens: Максимальная длина ответа.
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
Returns: error_text: Текст ошибки для пользователя в случае сбоя.
AI response 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 = { headers = {
"Authorization": f"Bearer {PERPLEXITY_API_KEY}", "Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
# Prepend system prompt to the history
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history)
payload = { payload = {
"model": PERPLEXITY_MODEL, "model": PERPLEXITY_MODEL,
"messages": messages, "messages": messages,
"max_tokens": 500, "max_tokens": max_tokens,
"temperature": 1.0, "temperature": temperature,
} }
try: try:
response = requests.post( response = requests.post(
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30 PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30
) )
response.raise_for_status() response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx)
data = response.json() data = response.json()
ai_response = data["choices"][0]["message"]["content"] return data["choices"][0]["message"]["content"]
print(f"💬 Ответ AI: {ai_response[:100]}...")
return ai_response
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
return "Извините, сервер не отвечает. Попробуйте позже." return "Извините, сервер не отвечает. Попробуйте позже."
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
print(f"❌ Ошибка API: {e}") print(f"❌ Ошибка API: {e}")
return "Произошла ошибка при обращении к AI. Попробуйте ещё раз." return error_text
except (KeyError, IndexError) as e: except (KeyError, IndexError) as e:
print(f"❌ Ошибка парсинга ответа: {e}") print(f"❌ Ошибка парсинга ответа: {e}")
return "Не удалось обработать ответ от AI." 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: def translate_text(text: str, source_lang: str, target_lang: str) -> str:
""" """
Translate text using Perplexity AI. Запрос к 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
""" """
if not text: if not text:
return "Извините, я не расслышал текст для перевода." 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]}...") print(f"🌍 Перевод: {source_name} -> {target_name}: {text[:60]}...")
headers = { # Формируем промпт с подстановкой языков
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
}
messages = [ messages = [
{ {
"role": "system", "role": "system",
@@ -114,28 +118,10 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str:
{"role": "user", "content": text}, {"role": "user", "content": text},
] ]
payload = { response = _send_request(
"model": PERPLEXITY_MODEL, messages,
"messages": messages, max_tokens=400,
"max_tokens": 400, temperature=0.2, # Низкая температура для точности перевода
"temperature": 0.2, error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
} )
return response.strip()
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 "Не удалось обработать перевод."

View File

@@ -4,43 +4,49 @@ Removes markdown formatting and special characters from AI responses.
Handles complex number-to-text conversion for Russian language. Handles complex number-to-text conversion for Russian language.
""" """
# Модуль очистки текста перед озвучкой.
# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы.
# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей").
# Это критически важно для качественного русского TTS.
import re import re
import pymorphy3 import pymorphy3
from num2words import num2words from num2words import num2words
# Initialize morphological analyzer # Инициализация морфологического анализатора (для определения падежей)
morph = pymorphy3.MorphAnalyzer() morph = pymorphy3.MorphAnalyzer()
# Preposition to case mapping (simplified heuristics) # Карта предлогов и падежей.
# Помогает понять, в какой падеж ставить число после предлога.
PREPOSITION_CASES = { PREPOSITION_CASES = {
"в": "loct", # Prepositional (Locative 2) or Accusative. 'v godu' -> loct "в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
"во": "loct", "во": "loct",
"на": "accs", # Dates: 'na 5 maya' -> Accusative (na pyatoe) "на": "accs", # На какое число? (Винительный) - для дат.
"о": "loct", "о": "loct",
"об": "loct", "об": "loct",
"обо": "loct", "обо": "loct",
"при": "loct", "при": "loct",
"у": "gent", "у": "gent", # У кого/чего? (Родительный)
"от": "gent", "от": "gent",
"до": "gent", "до": "gent",
"из": "gent", "из": "gent",
"с": "gent", # or ablt (instrumental) "с": "gent", # Или Творительный. Но чаще Родительный (с 5 числа).
"со": "gent", "со": "gent",
"без": "gent", "без": "gent",
"для": "gent", "для": "gent",
"вокруг": "gent", "вокруг": "gent",
"после": "gent", "после": "gent",
"к": "datv", "к": "datv", # К кому/чему? (Дательный)
"ко": "datv", "ко": "datv",
"по": "datv", # or accs for dates (limit). Heuristic: datv defaults usually. "по": "datv",
"над": "ablt", "над": "ablt", # Над кем/чем? (Творительный)
"под": "ablt", "под": "ablt",
"перед": "ablt", "перед": "ablt",
"за": "ablt", # or acc "за": "ablt",
"между": "ablt", "между": "ablt",
} }
# Mapping pymorphy cases to num2words cases # Соответствие падежей pymorphy и библиотеки num2words
PYMORPHY_TO_NUM2WORDS = { PYMORPHY_TO_NUM2WORDS = {
"nomn": "nominative", "nomn": "nominative",
"gent": "genitive", "gent": "genitive",
@@ -48,13 +54,13 @@ PYMORPHY_TO_NUM2WORDS = {
"accs": "accusative", "accs": "accusative",
"ablt": "instrumental", "ablt": "instrumental",
"loct": "prepositional", "loct": "prepositional",
"voct": "nominative", # Fallback "voct": "nominative",
"gen2": "genitive", "gen2": "genitive",
"acc2": "accusative", "acc2": "accusative",
"loc2": "prepositional", "loc2": "prepositional",
} }
# Month names in Genitive case (as they appear in dates) # Названия месяцев в родительном падеже (для поиска дат в тексте)
MONTHS_GENITIVE = [ MONTHS_GENITIVE = [
"января", "января",
"февраля", "февраля",
@@ -72,16 +78,20 @@ MONTHS_GENITIVE = [
def get_case_from_preposition(prep_token): def get_case_from_preposition(prep_token):
"""Return pymorphy case based on preposition.""" """Определяет падеж по предлогу."""
if not prep_token: if not prep_token:
return None return None
return PREPOSITION_CASES.get(prep_token.lower()) return PREPOSITION_CASES.get(prep_token.lower())
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"): 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: try:
# Handle floats # Обработка дробей (замена запятой на точку)
if "." in number_str or "," in number_str: if "." in number_str or "," in number_str:
num_val = float(number_str.replace(",", ".")) num_val = float(number_str.replace(",", "."))
else: else:
@@ -95,31 +105,25 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende
def numbers_to_words(text: str) -> str: 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: if not text:
return "" return ""
# 1. Identify "Year" patterns: "1999 год", "в 2024 году" # 1. Обработка годов: "в 1999 году", "2024 год"
def replace_year_match(match): def replace_year_match(match):
full_str = match.group(0) full_str = match.group(0)
prep = match.group(1) # Could be None prep = match.group(1) # Предлог (в, с, к...)
year_str = match.group(2) year_str = match.group(2) # Само число
year_word = match.group(3) # год, году, года... year_word = match.group(3) # Слово "год", "году" и т.д.
# Определяем падеж слова "год" через pymorphy
parsed = morph.parse(year_word)[0] parsed = morph.parse(year_word)[0]
case_tag = parsed.tag.case 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") nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
words = convert_number( words = convert_number(
year_str, context_type="ordinal", case=nw_case, gender="m" 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 "" prefix = f"{prep} " if prep else ""
return f"{prefix}{words} {year_word}" return f"{prefix}{words} {year_word}"
# Регулярка для годов
text = re.sub( text = re.sub(
r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b", r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b",
replace_year_match, replace_year_match,
text, text,
) )
# 2. Identify "Date" patterns: "25 июня", "с 1 мая" # 2. Обработка дат: "25 июня", "с 1 мая"
# Matches: (Preposition)? (Day) (Month_Genitive)
# Day is usually 1-31.
month_regex = "|".join(MONTHS_GENITIVE) month_regex = "|".join(MONTHS_GENITIVE)
def replace_date_match(match): def replace_date_match(match):
@@ -143,46 +146,39 @@ def numbers_to_words(text: str) -> str:
day_str = match.group(2) day_str = match.group(2)
month_word = match.group(3) month_word = match.group(3)
# Determine case # По умолчанию родительный падеж ("двадцать пятого июня")
# Default to Genitive ("25 июня" -> "двадцать пятого июня")
case = "genitive" case = "genitive"
if prep: if prep:
prep_clean = prep.strip().lower() prep_clean = prep.strip().lower()
# Specific overrides for dates # Специфичные правила для дат
if prep_clean == "на": if prep_clean == "на":
case = "accusative" # на 5 мая -> на пятое case = "accusative" # на пятое мая
elif prep_clean == "по": elif prep_clean == "по":
case = "accusative" # по 5 мая -> по пятое (limit) case = "accusative" # по пятое
elif prep_clean == "к": elif prep_clean == "к":
case = "dative" # к 5 мая -> к пятому case = "dative" # к пятому
elif prep_clean in ["с", "до", "от"]: elif prep_clean in ["с", "до", "от"]:
case = "genitive" # с 5 мая -> с пятого case = "genitive" # с пятого
else: else:
# Fallback to general preposition map
morph_case = get_case_from_preposition(prep_clean) morph_case = get_case_from_preposition(prep_clean)
if morph_case: if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive") case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
# Convert to Ordinal # Используем средний род ('n') для дат (число - средний род: пятое, пятого)
# 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 (пятое, пятого, пятому).
words = convert_number(day_str, context_type="ordinal", case=case, gender="n") words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
prefix = f"{prep} " if prep else "" prefix = f"{prep} " if prep else ""
return f"{prefix}{words} {month_word}" return f"{prefix}{words} {month_word}"
# Конкатенация regex для месяцев (ВАЖНО: month_regex должен быть вставлен в строку)
text = re.sub( 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, replace_date_match,
text, text,
) )
# 3. Handle remaining numbers (Cardinals) # 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
def replace_cardinal_match(match): def replace_cardinal_match(match):
prep = match.group(1) prep = match.group(1)
num_str = match.group(2) 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: def clean_response(text: str, language: str = "ru") -> str:
""" """
Clean AI response from markdown formatting and special characters. Основная функция очистки.
Убирает Markdown, ссылки, мусор и преобразует числа.
Args: Args:
text: Raw AI response with possible markdown text: Сырой текст от AI.
language: Target language for output (affects post-processing) language: Язык (для конвертации чисел, работает только для ru).
Returns:
Clean text suitable for TTS
""" """
if not text: if not text:
return "" return ""
# Remove citation references like [1], [2], [citation], etc. # Удаление ссылок на источники [1], [citation needed]
# Using hex escapes for brackets to avoid escaping issues
text = re.sub(r"\x5B\d+\x5D", "", text) text = re.sub(r"\x5B\d+\x5D", "", text)
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE) text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
text = re.sub(r"\x5Bsource\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)
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"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text) text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Remove markdown strikethrough ~~text~~ # Удаление зачеркнутого ~~text~~
text = re.sub(r"~~(.+?)~~", r"\1", 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) 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) text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
# Remove markdown images ![alt](url) # Удаление картинок ![alt](url) -> удаляем полностью
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text) text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
# Remove inline code `code` # Удаление inline кода `code`
text = re.sub(r"`([^`]+)`", r"\1", text) text = re.sub(r"`([^`]+)`", r"\1", text)
# Remove code blocks ```code``` # Удаление блоков кода ```code```
text = re.sub(r"```[\s\S]*?```", "", text) 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*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*\d+\.\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) text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE)
# Remove horizontal rules # Удаление горизонтальных линий ---
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE) text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
# Remove HTML tags if any # Удаление HTML тегов
text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"<[^>]+>", "", text)
# Remove informal slang greetings at the beginning of sentences/responses # 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"\n{3,}", "\n\n", text)
text = re.sub(r" +", " ", text) text = re.sub(r" +", " ", text)
# Clean up and return return text.strip()
text = text.strip()
return text

58
app/core/config.py Normal file
View 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
View File

View File

@@ -1,19 +1,21 @@
""" """Alarm clock module."""
Alarm clock module.
Handles alarm scheduling, persistence, and playback. # Модуль будильника.
""" # Отвечает за хранение будильников (в JSON файле), их проверку и воспроизведение звука.
import json import json
import time
import subprocess import subprocess
import re import re
import threading
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from config import BASE_DIR from ..core.config import BASE_DIR
from local_stt import listen_for_keywords 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: class AlarmClock:
def __init__(self): def __init__(self):
@@ -21,7 +23,7 @@ class AlarmClock:
self.load_alarms() self.load_alarms()
def load_alarms(self): def load_alarms(self):
"""Load alarms from JSON file.""" """Загрузка списка будильников из JSON файла."""
if ALARM_FILE.exists(): if ALARM_FILE.exists():
try: try:
with open(ALARM_FILE, "r", encoding="utf-8") as f: with open(ALARM_FILE, "r", encoding="utf-8") as f:
@@ -31,7 +33,7 @@ class AlarmClock:
self.alarms = [] self.alarms = []
def save_alarms(self): def save_alarms(self):
"""Save alarms to JSON file.""" """Сохранение списка будильников в JSON файл."""
try: try:
with open(ALARM_FILE, "w", encoding="utf-8") as f: with open(ALARM_FILE, "w", encoding="utf-8") as f:
json.dump(self.alarms, f, indent=4) json.dump(self.alarms, f, indent=4)
@@ -39,49 +41,45 @@ class AlarmClock:
print(f"❌ Ошибка сохранения будильников: {e}") print(f"❌ Ошибка сохранения будильников: {e}")
def add_alarm(self, hour: int, minute: int): def add_alarm(self, hour: int, minute: int):
"""Add a new alarm.""" """Добавление нового будильника (или обновление существующего)."""
# Check if already exists
for alarm in self.alarms: for alarm in self.alarms:
if alarm["hour"] == hour and alarm["minute"] == minute: if alarm["hour"] == hour and alarm["minute"] == minute:
alarm["active"] = True alarm["active"] = True
self.save_alarms() self.save_alarms()
return return
self.alarms.append({ self.alarms.append({"hour": hour, "minute": minute, "active": True})
"hour": hour,
"minute": minute,
"active": True
})
self.save_alarms() self.save_alarms()
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}") print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}")
def cancel_all_alarms(self): def cancel_all_alarms(self):
"""Cancel all active alarms.""" """Выключение (деактивация) всех будильников."""
for alarm in self.alarms: for alarm in self.alarms:
alarm["active"] = False alarm["active"] = False
self.save_alarms() self.save_alarms()
print("🔕 Все будильники отменены.") print("🔕 Все будильники отменены.")
def check_alarms(self): def check_alarms(self):
"""Check if any alarm should trigger now. Returns True if triggered.""" """
Проверка: не пора ли звенеть?
Вызывается в главном цикле.
Возвращает True, если будильник сработал.
"""
now = datetime.now() now = datetime.now()
triggered = False triggered = False
for alarm in self.alarms: for alarm in self.alarms:
if alarm["active"]: if alarm["active"]:
if alarm["hour"] == now.hour and alarm["minute"] == now.minute: if alarm["hour"] == now.hour and alarm["minute"] == now.minute:
# Prevent re-triggering within the same minute? print(
# We should disable it immediately or track last trigger time. f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
# For simple logic: disable it (one-time alarm). )
alarm["active"] = (
# But wait, checking every second? False # Одноразовый будильник, выключаем после срабатывания
# If I disable it, it won't ring for the whole minute. )
# Correct.
print(f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}")
alarm["active"] = False
triggered = True triggered = True
self.trigger_alarm() self.trigger_alarm() # Запуск звука и ожидание стоп-слова
break # Trigger one at a time break # Звоним только один за раз
if triggered: if triggered:
self.save_alarms() self.save_alarms()
@@ -89,27 +87,37 @@ class AlarmClock:
return False return False
def trigger_alarm(self): def trigger_alarm(self):
"""Play alarm sound and wait for stop command.""" """
Логика срабатывания будильника.
Запускает воспроизведение MP3 через mpg123 и слушает команду "Стоп".
Использует локальное распознавание (Vosk), чтобы не зависеть от интернета.
"""
print("🔔 БУДИЛЬНИК ЗВОНИТ! (Скажите 'Стоп' или 'Александр стоп')") print("🔔 БУДИЛЬНИК ЗВОНИТ! (Скажите 'Стоп' или 'Александр стоп')")
# Start playing sound in loop # Запуск плеера mpg123 в бесконечном цикле (--loop -1)
# -q for quiet (no output)
# --loop -1 for infinite loop
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)] cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
try: try:
process = subprocess.Popen(cmd) process = subprocess.Popen(cmd)
except FileNotFoundError: except FileNotFoundError:
print("❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123") print(
"❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123"
)
return return
try: try:
# Listen for stop command using local Vosk stop_words = [
# Loop until stop word is heard "стоп",
stop_words = ["стоп", "хватит", "тихо", "замолчи", "отмена", "александр стоп"] "хватит",
"тихо",
"замолчи",
"отмена",
"александр стоп",
]
# Цикл ожидания стоп-команды
while True: while True:
# Listen in short bursts to be responsive # Слушаем локально (без интернета)
text = listen_for_keywords(stop_words, timeout=3.0) text = listen_for_keywords(stop_words, timeout=3.0)
if text: if text:
print(f"🛑 Будильник остановлен по команде: '{text}'") print(f"🛑 Будильник остановлен по команде: '{text}'")
@@ -118,7 +126,7 @@ class AlarmClock:
except Exception as e: except Exception as e:
print(f"❌ Ошибка во время будильника: {e}") print(f"❌ Ошибка во время будильника: {e}")
finally: finally:
# Kill the player # Обязательно убиваем процесс плеера
process.terminate() process.terminate()
try: try:
process.wait(timeout=1) process.wait(timeout=1)
@@ -128,8 +136,8 @@ class AlarmClock:
def parse_command(self, text: str) -> str | None: 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() text = text.lower()
if "будильник" not in text and "разбуди" not in text: if "будильник" not in text and "разбуди" not in text:
@@ -139,40 +147,26 @@ class AlarmClock:
self.cancel_all_alarms() self.cancel_all_alarms()
return "Хорошо, я отменил все будильники." return "Хорошо, я отменил все будильники."
# Regex to find time: HH:MM, HH-MM, HH MM, HH часов MM минут # Поиск формата "7:30", "7.30"
# 1. "07:30", "7:30" match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
match = re.search(r'\b(\d{1,2})[:.-](\d{2})\b', text)
if match: if match:
h, m = int(match.group(1)), int(match.group(2)) h, m = int(match.group(1)), int(match.group(2))
if 0 <= h <= 23 and 0 <= m <= 59: if 0 <= h <= 23 and 0 <= m <= 59:
self.add_alarm(h, m) self.add_alarm(h, m)
return f"Я установил будильник на {h} часов {m} минут." return f"Я установил будильник на {h} часов {m} минут."
# 2. "7 часов 30 минут" or "7 30" # Поиск формата словами "на 7 часов 15 минут"
# Search for pattern: digits ... (digits)? match_time = re.search(
# Complex to separate from other numbers. r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?",
text,
# 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)
if match_time: if match_time:
h = int(match_time.group(1)) h = int(match_time.group(1))
m = int(match_time.group(2)) if match_time.group(2) else 0 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: if "вечера" in text and h < 12:
h += 12 h += 12
elif "утра" in text and h == 12: elif "утра" in text and h == 12:
@@ -184,9 +178,11 @@ class AlarmClock:
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'." return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
# Global instance
# Глобальный экземпляр
_alarm_clock = None _alarm_clock = None
def get_alarm_clock(): def get_alarm_clock():
global _alarm_clock global _alarm_clock
if _alarm_clock is None: if _alarm_clock is None:

348
app/main.py Normal file
View 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()

View File

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

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

View File

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

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

View File

@@ -1,5 +1,5 @@
# Простые проверки для модуля cleaner (запуск вручную).
import cleaner from app.core import cleaner
import traceback import traceback
try: try:

272
tts.py
View File

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

View File

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