другая структура проекта + 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

0
app/__init__.py Normal file
View File

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

147
app/audio/local_stt.py Normal file
View File

@@ -0,0 +1,147 @@
"""
Local offline Speech-to-Text module using Vosk.
Used for simple command detection (like "stop") without internet.
"""
# Модуль локального распознавания речи (Vosk).
# Работает полностью оффлайн (без интернета).
# Используется, когда нужно распознать простые команды (например, "стоп" во время будильника),
# чтобы не тратить трафик и время на обращение к облаку.
import os
import sys
import json
import pyaudio
from vosk import Model, KaldiRecognizer
from ..core.config import VOSK_MODEL_PATH, SAMPLE_RATE
class LocalRecognizer:
"""Класс для работы с Vosk."""
def __init__(self):
self.model = None
self.rec = None
self.pa = None
self.stream = None
def initialize(self):
"""Загрузка модели Vosk."""
if not os.path.exists(VOSK_MODEL_PATH):
print(f"❌ Ошибка: Vosk модель не найдена по пути {VOSK_MODEL_PATH}")
return False
print("📦 Инициализация локального STT (Vosk)...")
# Трюк для подавления вывода логов Vosk в консоль (он очень шумный)
try:
null_fd = os.open(os.devnull, os.O_WRONLY)
old_stderr = os.dup(2)
sys.stderr.flush()
os.dup2(null_fd, 2)
os.close(null_fd)
# Сама загрузка модели
self.model = Model(str(VOSK_MODEL_PATH))
# Возвращаем stderr обратно
os.dup2(old_stderr, 2)
os.close(old_stderr)
except Exception as e:
print(f"Error initializing Vosk: {e}")
return False
self.rec = KaldiRecognizer(self.model, SAMPLE_RATE)
self.pa = pyaudio.PyAudio()
return True
def listen_for_keywords(self, keywords: list, timeout: float = 10.0) -> str:
"""
Слушает микрофон заданное время и проверяет наличие ключевых слов.
Args:
keywords: Список слов, которые мы ждем (например, ["стоп", "хватит"]).
timeout: Сколько секунд слушать.
Returns:
Найденное слово или пустую строку.
"""
if not self.model:
if not self.initialize():
return ""
# Открываем поток микрофона
try:
stream = self.pa.open(
format=pyaudio.paInt16,
channels=1,
rate=SAMPLE_RATE,
input=True,
frames_per_buffer=4096,
)
stream.start_stream()
except Exception as e:
print(f"❌ Ошибка микрофона: {e}")
return ""
import time
start_time = time.time()
print(f"👂 Локальное слушание ожидает: {keywords}")
detected_text = ""
try:
while time.time() - start_time < timeout:
data = stream.read(4096, exception_on_overflow=False)
# Vosk обрабатывает аудио чанками
if self.rec.AcceptWaveform(data):
# Полный результат
res = json.loads(self.rec.Result())
text = res.get("text", "")
if text:
print(f"📝 Локально: {text}")
# Проверяем, есть ли ключевое слово в распознанном тексте
for kw in keywords:
if kw in text:
detected_text = text
break
else:
# Частичный результат (быстрее, чем полный)
res = json.loads(self.rec.PartialResult())
partial = res.get("partial", "")
if partial:
for kw in keywords:
if kw in partial:
detected_text = partial
break
if detected_text:
break
finally:
stream.stop_stream()
stream.close()
return detected_text
def cleanup(self):
if self.pa:
self.pa.terminate()
# Глобальный экземпляр
_local_recognizer = None
def get_local_recognizer():
global _local_recognizer
if _local_recognizer is None:
_local_recognizer = LocalRecognizer()
return _local_recognizer
def listen_for_keywords(keywords: list, timeout: float = 5.0) -> str:
"""Внешняя функция для поиска ключевых слов."""
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

127
app/core/ai.py Normal file
View File

@@ -0,0 +1,127 @@
"""AI module for Perplexity API integration."""
# Модуль общения с искусственным интеллектом (Perplexity API).
# Обрабатывает запросы пользователя и переводы.
import requests
from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL
# Системный промпт (инструкция) для AI.
# Задает личность ассистента: имя "Александр", стиль общения, краткость.
SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением.
Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно.
Твоя главная цель — помогать пользователю и поддерживать интересный диалог.
Отвечай кратко и по существу, на русском языке.
Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом.
Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов.
ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные."""
# Системный промпт для режима переводчика.
# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод...").
TRANSLATION_SYSTEM_PROMPT = """You are a translation engine.
Translate from {source} to {target}.
Return only the translated text, without quotes, comments, or explanations."""
def _send_request(messages, max_tokens, temperature, error_text):
"""
Внутренняя функция для отправки HTTP-запроса к Perplexity API.
Args:
messages: Список сообщений (история чата).
max_tokens: Максимальная длина ответа.
temperature: "Креативность" (0.2 - строго, 1.0 - креативно).
error_text: Текст ошибки для пользователя в случае сбоя.
"""
headers = {
"Authorization": f"Bearer {PERPLEXITY_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": PERPLEXITY_MODEL,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
try:
response = requests.post(
PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30
)
response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx)
data = response.json()
return data["choices"][0]["message"]["content"]
except requests.exceptions.Timeout:
return "Извините, сервер не отвечает. Попробуйте позже."
except requests.exceptions.RequestException as e:
print(f"❌ Ошибка API: {e}")
return error_text
except (KeyError, IndexError) as e:
print(f"❌ Ошибка парсинга ответа: {e}")
return "Не удалось обработать ответ от AI."
def ask_ai(messages_history: list) -> str:
"""
Запрос к AI в режиме чата.
Принимает историю переписки, добавляет SYSTEM_PROMPT и отправляет запрос.
"""
if not messages_history:
return "Извините, я не расслышал вашу команду."
# Логирование последнего запроса
last_user_message = "Unknown"
for msg in reversed(messages_history):
if msg["role"] == "user":
last_user_message = msg["content"]
break
print(f"🤖 Запрос к AI: {last_user_message}")
# Формируем полный список сообщений с системной инструкцией в начале
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history)
response = _send_request(
messages,
max_tokens=500,
temperature=1.0, # Высокая температура для более живого общения
error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.",
)
if response:
print(f"💬 Ответ AI: {response[:100]}...")
return response
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
"""
Запрос к AI в режиме перевода.
Использует специальный промпт для переводчика.
"""
if not text:
return "Извините, я не расслышал текст для перевода."
lang_names = {"ru": "Russian", "en": "English"}
source_name = lang_names.get(source_lang, source_lang)
target_name = lang_names.get(target_lang, target_lang)
print(f"🌍 Перевод: {source_name} -> {target_name}: {text[:60]}...")
# Формируем промпт с подстановкой языков
messages = [
{
"role": "system",
"content": TRANSLATION_SYSTEM_PROMPT.format(
source=source_name, target=target_name
),
},
{"role": "user", "content": text},
]
response = _send_request(
messages,
max_tokens=400,
temperature=0.2, # Низкая температура для точности перевода
error_text="Произошла ошибка при переводе. Попробуйте ещё раз.",
)
return response.strip()

279
app/core/cleaner.py Normal file
View File

@@ -0,0 +1,279 @@
"""
Response cleaner module.
Removes markdown formatting and special characters from AI responses.
Handles complex number-to-text conversion for Russian language.
"""
# Модуль очистки текста перед озвучкой.
# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы.
# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей").
# Это критически важно для качественного русского TTS.
import re
import pymorphy3
from num2words import num2words
# Инициализация морфологического анализатора (для определения падежей)
morph = pymorphy3.MorphAnalyzer()
# Карта предлогов и падежей.
# Помогает понять, в какой падеж ставить число после предлога.
PREPOSITION_CASES = {
"в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов.
"во": "loct",
"на": "accs", # На какое число? (Винительный) - для дат.
"о": "loct",
"об": "loct",
"обо": "loct",
"при": "loct",
"у": "gent", # У кого/чего? (Родительный)
"от": "gent",
"до": "gent",
"из": "gent",
"с": "gent", # Или Творительный. Но чаще Родительный (с 5 числа).
"со": "gent",
"без": "gent",
"для": "gent",
"вокруг": "gent",
"после": "gent",
"к": "datv", # К кому/чему? (Дательный)
"ко": "datv",
"по": "datv",
"над": "ablt", # Над кем/чем? (Творительный)
"под": "ablt",
"перед": "ablt",
"за": "ablt",
"между": "ablt",
}
# Соответствие падежей pymorphy и библиотеки num2words
PYMORPHY_TO_NUM2WORDS = {
"nomn": "nominative",
"gent": "genitive",
"datv": "dative",
"accs": "accusative",
"ablt": "instrumental",
"loct": "prepositional",
"voct": "nominative",
"gen2": "genitive",
"acc2": "accusative",
"loc2": "prepositional",
}
# Названия месяцев в родительном падеже (для поиска дат в тексте)
MONTHS_GENITIVE = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
def get_case_from_preposition(prep_token):
"""Определяет падеж по предлогу."""
if not prep_token:
return None
return PREPOSITION_CASES.get(prep_token.lower())
def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"):
"""
Обертка над num2words для конвертации числа в строку.
cardinal - количественное (один, два)
ordinal - порядковое (первый, второй)
"""
try:
# Обработка дробей (замена запятой на точку)
if "." in number_str or "," in number_str:
num_val = float(number_str.replace(",", "."))
else:
num_val = int(number_str)
return num2words(num_val, lang="ru", to=context_type, case=case, gender=gender)
except Exception as e:
print(f"Error converting number {number_str}: {e}")
return number_str
def numbers_to_words(text: str) -> str:
"""
Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи).
"""
if not text:
return ""
# 1. Обработка годов: "в 1999 году", "2024 год"
def replace_year_match(match):
full_str = match.group(0)
prep = match.group(1) # Предлог (в, с, к...)
year_str = match.group(2) # Само число
year_word = match.group(3) # Слово "год", "году" и т.д.
# Определяем падеж слова "год" через pymorphy
parsed = morph.parse(year_word)[0]
case_tag = parsed.tag.case
nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative")
# Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом)
words = convert_number(
year_str, context_type="ordinal", case=nw_case, gender="m"
)
prefix = f"{prep} " if prep else ""
return f"{prefix}{words} {year_word}"
# Регулярка для годов
text = re.sub(
r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b",
replace_year_match,
text,
)
# 2. Обработка дат: "25 июня", "с 1 мая"
month_regex = "|".join(MONTHS_GENITIVE)
def replace_date_match(match):
prep = match.group(1)
day_str = match.group(2)
month_word = match.group(3)
# По умолчанию родительный падеж ("двадцать пятого июня")
case = "genitive"
if prep:
prep_clean = prep.strip().lower()
# Специфичные правила для дат
if prep_clean == "на":
case = "accusative" # на пятое мая
elif prep_clean == "по":
case = "accusative" # по пятое
elif prep_clean == "к":
case = "dative" # к пятому
elif prep_clean in ["с", "до", "от"]:
case = "genitive" # с пятого
else:
morph_case = get_case_from_preposition(prep_clean)
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive")
# Используем средний род ('n') для дат (число - средний род: пятое, пятого)
words = convert_number(day_str, context_type="ordinal", case=case, gender="n")
prefix = f"{prep} " if prep else ""
return f"{prefix}{words} {month_word}"
# Конкатенация regex для месяцев (ВАЖНО: month_regex должен быть вставлен в строку)
text = re.sub(
r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+({month_regex})\b",
replace_date_match,
text,
)
# 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут)
def replace_cardinal_match(match):
prep = match.group(1)
num_str = match.group(2)
case = "nominative"
if prep:
morph_case = get_case_from_preposition(prep.strip())
if morph_case:
case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative")
words = convert_number(num_str, context_type="cardinal", case=case)
prefix = f"{prep} " if prep else ""
return f"{prefix}{words}"
text = re.sub(
r"(?i)\b((?:в|на|о|об|обо|при|у|от|до|из|с|со|без|для|вокруг|после|к|ко|по|над|под|перед|за|между)\s+)?(\d+(?:[.,]\d+)?)\b",
replace_cardinal_match,
text,
)
return text
def clean_response(text: str, language: str = "ru") -> str:
"""
Основная функция очистки.
Убирает Markdown, ссылки, мусор и преобразует числа.
Args:
text: Сырой текст от AI.
language: Язык (для конвертации чисел, работает только для ru).
"""
if not text:
return ""
# Удаление ссылок на источники [1], [citation needed]
text = re.sub(r"\x5B\d+\x5D", "", text)
text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE)
text = re.sub(r"\x5Bsource\x5D", "", text, flags=re.IGNORECASE)
# Удаление жирного шрифта **text** и __text__
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
# Удаление курсива *text* и _text_
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
# Удаление зачеркнутого ~~text~~
text = re.sub(r"~~(.+?)~~", r"\1", text)
# Удаление заголовков Markdown (# Header)
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
# Удаление ссылок [text](url) -> оставляем только text
# \x5B = [, \x5D = ]
text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text)
# Удаление картинок ![alt](url) -> удаляем полностью
text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text)
# Удаление inline кода `code`
text = re.sub(r"`([^`]+)`", r"\1", text)
# Удаление блоков кода ```code```
text = re.sub(r"```[\s\S]*?```", "", text)
# Удаление маркеров списков (-, *, 1.)
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*>\s*", "", text, flags=re.MULTILINE)
# Удаление горизонтальных линий ---
text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
# Удаление HTML тегов
text = re.sub(r"<[^>]+>", "", text)
# Remove informal slang greetings at the beginning of sentences/responses
text = re.sub(
r"^(Эй|Хэй|Слушай|Так|Ну|Короче|В\s+общем)[,!?:]?\s*",
"",
text,
flags=re.IGNORECASE | re.MULTILINE,
)
# Convert numbers to words only for Russian, and only if digits exist
if language == "ru" and re.search(r"\d", text):
text = numbers_to_words(text)
# Remove extra whitespace
text = re.sub(r"\n{3,}", "\n\n", text)
text = re.sub(r" +", " ", text)
return text.strip()

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

190
app/features/alarm.py Normal file
View File

@@ -0,0 +1,190 @@
"""Alarm clock module."""
# Модуль будильника.
# Отвечает за хранение будильников (в JSON файле), их проверку и воспроизведение звука.
import json
import subprocess
import re
from datetime import datetime
from pathlib import Path
from ..core.config import BASE_DIR
from ..audio.local_stt import listen_for_keywords
# Файл базы данных будильников
ALARM_FILE = BASE_DIR / "data" / "alarms.json"
# Звуковой файл сигнала
ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3"
class AlarmClock:
def __init__(self):
self.alarms = []
self.load_alarms()
def load_alarms(self):
"""Загрузка списка будильников из JSON файла."""
if ALARM_FILE.exists():
try:
with open(ALARM_FILE, "r", encoding="utf-8") as f:
self.alarms = json.load(f)
except Exception as e:
print(f"❌ Ошибка загрузки будильников: {e}")
self.alarms = []
def save_alarms(self):
"""Сохранение списка будильников в JSON файл."""
try:
with open(ALARM_FILE, "w", encoding="utf-8") as f:
json.dump(self.alarms, f, indent=4)
except Exception as e:
print(f"❌ Ошибка сохранения будильников: {e}")
def add_alarm(self, hour: int, minute: int):
"""Добавление нового будильника (или обновление существующего)."""
for alarm in self.alarms:
if alarm["hour"] == hour and alarm["minute"] == minute:
alarm["active"] = True
self.save_alarms()
return
self.alarms.append({"hour": hour, "minute": minute, "active": True})
self.save_alarms()
print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}")
def cancel_all_alarms(self):
"""Выключение (деактивация) всех будильников."""
for alarm in self.alarms:
alarm["active"] = False
self.save_alarms()
print("🔕 Все будильники отменены.")
def check_alarms(self):
"""
Проверка: не пора ли звенеть?
Вызывается в главном цикле.
Возвращает True, если будильник сработал.
"""
now = datetime.now()
triggered = False
for alarm in self.alarms:
if alarm["active"]:
if alarm["hour"] == now.hour and alarm["minute"] == now.minute:
print(
f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}"
)
alarm["active"] = (
False # Одноразовый будильник, выключаем после срабатывания
)
triggered = True
self.trigger_alarm() # Запуск звука и ожидание стоп-слова
break # Звоним только один за раз
if triggered:
self.save_alarms()
return True
return False
def trigger_alarm(self):
"""
Логика срабатывания будильника.
Запускает воспроизведение MP3 через mpg123 и слушает команду "Стоп".
Использует локальное распознавание (Vosk), чтобы не зависеть от интернета.
"""
print("🔔 БУДИЛЬНИК ЗВОНИТ! (Скажите 'Стоп' или 'Александр стоп')")
# Запуск плеера mpg123 в бесконечном цикле (--loop -1)
cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)]
try:
process = subprocess.Popen(cmd)
except FileNotFoundError:
print(
"❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123"
)
return
try:
stop_words = [
"стоп",
"хватит",
"тихо",
"замолчи",
"отмена",
"александр стоп",
]
# Цикл ожидания стоп-команды
while True:
# Слушаем локально (без интернета)
text = listen_for_keywords(stop_words, timeout=3.0)
if text:
print(f"🛑 Будильник остановлен по команде: '{text}'")
break
except Exception as e:
print(f"❌ Ошибка во время будильника: {e}")
finally:
# Обязательно убиваем процесс плеера
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
print("🔕 Будильник выключен.")
def parse_command(self, text: str) -> str | None:
"""
Парсинг команды установки будильника из текста.
Примеры: "разбуди в 7:30", "будильник на 8 утра".
"""
text = text.lower()
if "будильник" not in text and "разбуди" not in text:
return None
if "отмени" in text:
self.cancel_all_alarms()
return "Хорошо, я отменил все будильники."
# Поиск формата "7:30", "7.30"
match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text)
if match:
h, m = int(match.group(1)), int(match.group(2))
if 0 <= h <= 23 and 0 <= m <= 59:
self.add_alarm(h, m)
return f"Я установил будильник на {h} часов {m} минут."
# Поиск формата словами "на 7 часов 15 минут"
match_time = re.search(
r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?",
text,
)
if match_time:
h = int(match_time.group(1))
m = int(match_time.group(2)) if match_time.group(2) else 0
# Умная коррекция времени (если говорят "в 8", а сейчас 9, то это скорее 8 вечера или 8 утра завтра)
# Здесь простая логика AM/PM
if "вечера" in text and h < 12:
h += 12
elif "утра" in text and h == 12:
h = 0
if 0 <= h <= 23 and 0 <= m <= 59:
self.add_alarm(h, m)
return f"Хорошо, разбужу вас в {h}:{m:02d}."
return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'."
# Глобальный экземпляр
_alarm_clock = None
def get_alarm_clock():
global _alarm_clock
if _alarm_clock is None:
_alarm_clock = AlarmClock()
return _alarm_clock

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