diff --git a/.gitignore b/.gitignore index 618cf95..1ebe43c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ vosk-model-*/ # VS Code .vscode/ + + +.beads +.gitattributes diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..df7a4af --- /dev/null +++ b/AGENTS.md @@ -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 # View issue details +bd update --status in_progress # Claim work +bd close # 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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..51fd6cf --- /dev/null +++ b/README.md @@ -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`. diff --git a/alarms.json b/alarms.json deleted file mode 100644 index c6998bd..0000000 --- a/alarms.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "hour": 10, - "minute": 15, - "active": true - }, - { - "hour": 3, - "minute": 42, - "active": false - } -] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio/__init__.py b/app/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/local_stt.py b/app/audio/local_stt.py similarity index 60% rename from local_stt.py rename to app/audio/local_stt.py index f53d648..151b0e7 100644 --- a/local_stt.py +++ b/app/audio/local_stt.py @@ -2,14 +2,23 @@ Local offline Speech-to-Text module using Vosk. Used for simple command detection (like "stop") without internet. """ + +# Модуль локального распознавания речи (Vosk). +# Работает полностью оффлайн (без интернета). +# Используется, когда нужно распознать простые команды (например, "стоп" во время будильника), +# чтобы не тратить трафик и время на обращение к облаку. + import os import sys import json import pyaudio from vosk import Model, KaldiRecognizer -from config import VOSK_MODEL_PATH, SAMPLE_RATE +from ..core.config import VOSK_MODEL_PATH, SAMPLE_RATE + class LocalRecognizer: + """Класс для работы с Vosk.""" + def __init__(self): self.model = None self.rec = None @@ -17,22 +26,25 @@ class LocalRecognizer: self.stream = None def initialize(self): + """Загрузка модели Vosk.""" if not os.path.exists(VOSK_MODEL_PATH): print(f"❌ Ошибка: Vosk модель не найдена по пути {VOSK_MODEL_PATH}") return False - + print("📦 Инициализация локального STT (Vosk)...") - # Redirect stderr to suppress Vosk logs + + # Трюк для подавления вывода логов Vosk в консоль (он очень шумный) try: null_fd = os.open(os.devnull, os.O_WRONLY) old_stderr = os.dup(2) sys.stderr.flush() os.dup2(null_fd, 2) os.close(null_fd) - + + # Сама загрузка модели self.model = Model(str(VOSK_MODEL_PATH)) - - # Restore stderr + + # Возвращаем stderr обратно os.dup2(old_stderr, 2) os.close(old_stderr) except Exception as e: @@ -45,72 +57,91 @@ class LocalRecognizer: def listen_for_keywords(self, keywords: list, timeout: float = 10.0) -> str: """ - Listen for specific keywords locally. - Returns the recognized keyword if found, or empty string. + Слушает микрофон заданное время и проверяет наличие ключевых слов. + + Args: + keywords: Список слов, которые мы ждем (например, ["стоп", "хватит"]). + timeout: Сколько секунд слушать. + + Returns: + Найденное слово или пустую строку. """ if not self.model: if not self.initialize(): return "" - # Open stream + # Открываем поток микрофона try: - stream = self.pa.open(format=pyaudio.paInt16, channels=1, rate=SAMPLE_RATE, input=True, frames_per_buffer=4096) + stream = self.pa.open( + format=pyaudio.paInt16, + channels=1, + rate=SAMPLE_RATE, + input=True, + frames_per_buffer=4096, + ) stream.start_stream() except Exception as e: print(f"❌ Ошибка микрофона: {e}") return "" import time + start_time = time.time() - + print(f"👂 Локальное слушание ожидает: {keywords}") - + detected_text = "" - + try: while time.time() - start_time < timeout: data = stream.read(4096, exception_on_overflow=False) + + # Vosk обрабатывает аудио чанками if self.rec.AcceptWaveform(data): + # Полный результат res = json.loads(self.rec.Result()) text = res.get("text", "") if text: print(f"📝 Локально: {text}") - # Check against keywords + # Проверяем, есть ли ключевое слово в распознанном тексте for kw in keywords: if kw in text: detected_text = text break else: - # Partial result + # Частичный результат (быстрее, чем полный) res = json.loads(self.rec.PartialResult()) partial = res.get("partial", "") if partial: - for kw in keywords: + for kw in keywords: if kw in partial: detected_text = partial break - + if detected_text: break finally: stream.stop_stream() stream.close() - + return detected_text def cleanup(self): if self.pa: self.pa.terminate() -# Global instance + +# Глобальный экземпляр _local_recognizer = None + def get_local_recognizer(): global _local_recognizer if _local_recognizer is None: _local_recognizer = LocalRecognizer() return _local_recognizer + def listen_for_keywords(keywords: list, timeout: float = 5.0) -> str: - """Listen for keywords using Vosk.""" + """Внешняя функция для поиска ключевых слов.""" return get_local_recognizer().listen_for_keywords(keywords, timeout) diff --git a/app/audio/sound_level.py b/app/audio/sound_level.py new file mode 100644 index 0000000..08f70b0 --- /dev/null +++ b/app/audio/sound_level.py @@ -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 diff --git a/app/audio/stt.py b/app/audio/stt.py new file mode 100644 index 0000000..8aacc5a --- /dev/null +++ b/app/audio/stt.py @@ -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 diff --git a/app/audio/tts.py b/app/audio/tts.py new file mode 100644 index 0000000..61c25bf --- /dev/null +++ b/app/audio/tts.py @@ -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() diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py new file mode 100644 index 0000000..b194f07 --- /dev/null +++ b/app/audio/wakeword.py @@ -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() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai.py b/app/core/ai.py similarity index 51% rename from ai.py rename to app/core/ai.py index 00919e9..0fd393c 100644 --- a/ai.py +++ b/app/core/ai.py @@ -1,13 +1,14 @@ -""" -AI module for Perplexity API integration. -Sends user queries and receives AI responses. -""" +"""AI module for Perplexity API integration.""" + +# Модуль общения с искусственным интеллектом (Perplexity API). +# Обрабатывает запросы пользователя и переводы. import requests -from config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL +from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL -# System prompt for the AI +# Системный промпт (инструкция) для AI. +# Задает личность ассистента: имя "Александр", стиль общения, краткость. SYSTEM_PROMPT = """Ты — Александр, умный голосовой ассистент с человеческим поведением. Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно. Твоя главная цель — помогать пользователю и поддерживать интересный диалог. @@ -16,79 +17,86 @@ SYSTEM_PROMPT = """Ты — Александр, умный голосовой а Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов. ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные.""" +# Системный промпт для режима переводчика. +# Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод..."). TRANSLATION_SYSTEM_PROMPT = """You are a translation engine. Translate from {source} to {target}. Return only the translated text, without quotes, comments, or explanations.""" -def ask_ai(messages_history: list) -> str: +def _send_request(messages, max_tokens, temperature, error_text): """ - Send a message history to Perplexity AI and get a response. + Внутренняя функция для отправки HTTP-запроса к Perplexity API. Args: - messages_history: List of dictionaries with role and content - e.g., [{"role": "user", "content": "Hi"}] - - Returns: - AI response text + messages: Список сообщений (история чата). + max_tokens: Максимальная длина ответа. + temperature: "Креативность" (0.2 - строго, 1.0 - креативно). + error_text: Текст ошибки для пользователя в случае сбоя. """ - if not messages_history: - return "Извините, я не расслышал вашу команду." - - # Extract the last user message for logging - last_user_message = next( - (m["content"] for m in reversed(messages_history) if m["role"] == "user"), - "Unknown", - ) - print(f"🤖 Запрос к AI: {last_user_message}") - headers = { "Authorization": f"Bearer {PERPLEXITY_API_KEY}", "Content-Type": "application/json", } - - # Prepend system prompt to the history - messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history) - payload = { "model": PERPLEXITY_MODEL, "messages": messages, - "max_tokens": 500, - "temperature": 1.0, + "max_tokens": max_tokens, + "temperature": temperature, } try: response = requests.post( PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30 ) - response.raise_for_status() - + response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx) data = response.json() - ai_response = data["choices"][0]["message"]["content"] - print(f"💬 Ответ AI: {ai_response[:100]}...") - return ai_response - + return data["choices"][0]["message"]["content"] except requests.exceptions.Timeout: return "Извините, сервер не отвечает. Попробуйте позже." except requests.exceptions.RequestException as e: print(f"❌ Ошибка API: {e}") - return "Произошла ошибка при обращении к AI. Попробуйте ещё раз." + return error_text except (KeyError, IndexError) as e: print(f"❌ Ошибка парсинга ответа: {e}") return "Не удалось обработать ответ от AI." +def ask_ai(messages_history: list) -> str: + """ + Запрос к AI в режиме чата. + Принимает историю переписки, добавляет SYSTEM_PROMPT и отправляет запрос. + """ + if not messages_history: + return "Извините, я не расслышал вашу команду." + + # Логирование последнего запроса + last_user_message = "Unknown" + for msg in reversed(messages_history): + if msg["role"] == "user": + last_user_message = msg["content"] + break + print(f"🤖 Запрос к AI: {last_user_message}") + + # Формируем полный список сообщений с системной инструкцией в начале + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history) + + response = _send_request( + messages, + max_tokens=500, + temperature=1.0, # Высокая температура для более живого общения + error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.", + ) + + if response: + print(f"💬 Ответ AI: {response[:100]}...") + return response + + def translate_text(text: str, source_lang: str, target_lang: str) -> str: """ - Translate text using Perplexity AI. - - Args: - text: Text to translate - source_lang: Source language code ("ru" or "en") - target_lang: Target language code ("ru" or "en") - - Returns: - Translated text + Запрос к AI в режиме перевода. + Использует специальный промпт для переводчика. """ if not text: return "Извините, я не расслышал текст для перевода." @@ -99,11 +107,7 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str: print(f"🌍 Перевод: {source_name} -> {target_name}: {text[:60]}...") - headers = { - "Authorization": f"Bearer {PERPLEXITY_API_KEY}", - "Content-Type": "application/json", - } - + # Формируем промпт с подстановкой языков messages = [ { "role": "system", @@ -114,28 +118,10 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str: {"role": "user", "content": text}, ] - payload = { - "model": PERPLEXITY_MODEL, - "messages": messages, - "max_tokens": 400, - "temperature": 0.2, - } - - try: - response = requests.post( - PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30 - ) - response.raise_for_status() - - data = response.json() - ai_response = data["choices"][0]["message"]["content"] - return ai_response.strip() - - except requests.exceptions.Timeout: - return "Извините, сервер не отвечает. Попробуйте позже." - except requests.exceptions.RequestException as e: - print(f"❌ Ошибка API перевода: {e}") - return "Произошла ошибка при переводе. Попробуйте ещё раз." - except (KeyError, IndexError) as e: - print(f"❌ Ошибка парсинга ответа перевода: {e}") - return "Не удалось обработать перевод." + response = _send_request( + messages, + max_tokens=400, + temperature=0.2, # Низкая температура для точности перевода + error_text="Произошла ошибка при переводе. Попробуйте ещё раз.", + ) + return response.strip() diff --git a/cleaner.py b/app/core/cleaner.py similarity index 55% rename from cleaner.py rename to app/core/cleaner.py index f338bd3..06f1936 100644 --- a/cleaner.py +++ b/app/core/cleaner.py @@ -4,43 +4,49 @@ Removes markdown formatting and special characters from AI responses. Handles complex number-to-text conversion for Russian language. """ +# Модуль очистки текста перед озвучкой. +# 1. Убирает Markdown (жирный шрифт, ссылки), который генерирует AI, чтобы робот не читал спецсимволы. +# 2. Преобразует числа в слова ("5 мая" -> "пятого мая", "5 рублей" -> "пять рублей"). +# Это критически важно для качественного русского TTS. + import re import pymorphy3 from num2words import num2words -# Initialize morphological analyzer +# Инициализация морфологического анализатора (для определения падежей) morph = pymorphy3.MorphAnalyzer() -# Preposition to case mapping (simplified heuristics) +# Карта предлогов и падежей. +# Помогает понять, в какой падеж ставить число после предлога. PREPOSITION_CASES = { - "в": "loct", # Prepositional (Locative 2) or Accusative. 'v godu' -> loct + "в": "loct", # В ком/чем? (Предложный) или Винительный. Часто loct для годов. "во": "loct", - "на": "accs", # Dates: 'na 5 maya' -> Accusative (na pyatoe) + "на": "accs", # На какое число? (Винительный) - для дат. "о": "loct", "об": "loct", "обо": "loct", "при": "loct", - "у": "gent", + "у": "gent", # У кого/чего? (Родительный) "от": "gent", "до": "gent", "из": "gent", - "с": "gent", # or ablt (instrumental) + "с": "gent", # Или Творительный. Но чаще Родительный (с 5 числа). "со": "gent", "без": "gent", "для": "gent", "вокруг": "gent", "после": "gent", - "к": "datv", + "к": "datv", # К кому/чему? (Дательный) "ко": "datv", - "по": "datv", # or accs for dates (limit). Heuristic: datv defaults usually. - "над": "ablt", + "по": "datv", + "над": "ablt", # Над кем/чем? (Творительный) "под": "ablt", "перед": "ablt", - "за": "ablt", # or acc + "за": "ablt", "между": "ablt", } -# Mapping pymorphy cases to num2words cases +# Соответствие падежей pymorphy и библиотеки num2words PYMORPHY_TO_NUM2WORDS = { "nomn": "nominative", "gent": "genitive", @@ -48,13 +54,13 @@ PYMORPHY_TO_NUM2WORDS = { "accs": "accusative", "ablt": "instrumental", "loct": "prepositional", - "voct": "nominative", # Fallback + "voct": "nominative", "gen2": "genitive", "acc2": "accusative", "loc2": "prepositional", } -# Month names in Genitive case (as they appear in dates) +# Названия месяцев в родительном падеже (для поиска дат в тексте) MONTHS_GENITIVE = [ "января", "февраля", @@ -72,16 +78,20 @@ MONTHS_GENITIVE = [ def get_case_from_preposition(prep_token): - """Return pymorphy case based on preposition.""" + """Определяет падеж по предлогу.""" if not prep_token: return None return PREPOSITION_CASES.get(prep_token.lower()) def convert_number(number_str, context_type="cardinal", case="nominative", gender="m"): - """Convert a number string to words with specific parameters.""" + """ + Обертка над num2words для конвертации числа в строку. + cardinal - количественное (один, два) + ordinal - порядковое (первый, второй) + """ try: - # Handle floats + # Обработка дробей (замена запятой на точку) if "." in number_str or "," in number_str: num_val = float(number_str.replace(",", ".")) else: @@ -95,31 +105,25 @@ def convert_number(number_str, context_type="cardinal", case="nominative", gende def numbers_to_words(text: str) -> str: """ - Intelligent conversion of digits in text to Russian words. - Handles years, dates, and basic case agreement. + Интеллектуальная замена цифр на слова с учетом контекста (даты, года, падежи). """ if not text: return "" - # 1. Identify "Year" patterns: "1999 год", "в 2024 году" + # 1. Обработка годов: "в 1999 году", "2024 год" def replace_year_match(match): full_str = match.group(0) - prep = match.group(1) # Could be None - year_str = match.group(2) - year_word = match.group(3) # год, году, года... + prep = match.group(1) # Предлог (в, с, к...) + year_str = match.group(2) # Само число + year_word = match.group(3) # Слово "год", "году" и т.д. + # Определяем падеж слова "год" через pymorphy parsed = morph.parse(year_word)[0] case_tag = parsed.tag.case - if ( - prep - and prep.strip().lower() in ["в", "во"] - and case_tag in ["accs", "nomn"] - ): - pass - nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative") + # Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом) words = convert_number( year_str, context_type="ordinal", case=nw_case, gender="m" ) @@ -127,15 +131,14 @@ def numbers_to_words(text: str) -> str: prefix = f"{prep} " if prep else "" return f"{prefix}{words} {year_word}" + # Регулярка для годов text = re.sub( r"(?i)\b((?:в|с|к|до|от)\s+)?(\d{3,4})\s+(год[а-я]*)\b", replace_year_match, text, ) - # 2. Identify "Date" patterns: "25 июня", "с 1 мая" - # Matches: (Preposition)? (Day) (Month_Genitive) - # Day is usually 1-31. + # 2. Обработка дат: "25 июня", "с 1 мая" month_regex = "|".join(MONTHS_GENITIVE) def replace_date_match(match): @@ -143,46 +146,39 @@ def numbers_to_words(text: str) -> str: day_str = match.group(2) month_word = match.group(3) - # Determine case - # Default to Genitive ("25 июня" -> "двадцать пятого июня") + # По умолчанию родительный падеж ("двадцать пятого июня") case = "genitive" if prep: prep_clean = prep.strip().lower() - # Specific overrides for dates + # Специфичные правила для дат if prep_clean == "на": - case = "accusative" # на 5 мая -> на пятое + case = "accusative" # на пятое мая elif prep_clean == "по": - case = "accusative" # по 5 мая -> по пятое (limit) + case = "accusative" # по пятое elif prep_clean == "к": - case = "dative" # к 5 мая -> к пятому + case = "dative" # к пятому elif prep_clean in ["с", "до", "от"]: - case = "genitive" # с 5 мая -> с пятого + case = "genitive" # с пятого else: - # Fallback to general preposition map morph_case = get_case_from_preposition(prep_clean) if morph_case: case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "genitive") - # Convert to Ordinal - # Dates are neuter ("число" implies neuter: "пятое", "пятого") - # However, num2words for genitive ordinal: - # 5, ordinal, genitive -> "пятого" (masc/neut are same) - # 5, ordinal, accusative -> "пятое" (neuter) vs "пятый" (masc inanimate?) - # Let's specify gender='n' (neuter) for dates to be safe (пятое, пятого, пятому). - + # Используем средний род ('n') для дат (число - средний род: пятое, пятого) words = convert_number(day_str, context_type="ordinal", case=case, gender="n") prefix = f"{prep} " if prep else "" return f"{prefix}{words} {month_word}" + # Конкатенация regex для месяцев (ВАЖНО: month_regex должен быть вставлен в строку) text = re.sub( - r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+(" + month_regex + r")\b", + r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+({month_regex})\b", replace_date_match, text, ) - # 3. Handle remaining numbers (Cardinals) + # 3. Обработка всех остальных чисел (Количественные: пять столов, десять минут) def replace_cardinal_match(match): prep = match.group(1) num_str = match.group(2) @@ -209,61 +205,59 @@ def numbers_to_words(text: str) -> str: def clean_response(text: str, language: str = "ru") -> str: """ - Clean AI response from markdown formatting and special characters. + Основная функция очистки. + Убирает Markdown, ссылки, мусор и преобразует числа. Args: - text: Raw AI response with possible markdown - language: Target language for output (affects post-processing) - - Returns: - Clean text suitable for TTS + text: Сырой текст от AI. + language: Язык (для конвертации чисел, работает только для ru). """ if not text: return "" - # Remove citation references like [1], [2], [citation], etc. - # Using hex escapes for brackets to avoid escaping issues + # Удаление ссылок на источники [1], [citation needed] text = re.sub(r"\x5B\d+\x5D", "", text) text = re.sub(r"\x5Bcitation\s*needed\x5D", "", text, flags=re.IGNORECASE) text = re.sub(r"\x5Bsource\x5D", "", text, flags=re.IGNORECASE) - # Remove markdown bold **text** and __text__ + # Удаление жирного шрифта **text** и __text__ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"__(.+?)__", r"\1", text) - # Remove markdown italic *text* and _text_ + # Удаление курсива *text* и _text_ text = re.sub(r"\*(.+?)\*", r"\1", text) text = re.sub(r"(? text + # Удаление ссылок [text](url) -> оставляем только text + # \x5B = [, \x5D = ] 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) - # Remove inline code `code` + # Удаление inline кода `code` text = re.sub(r"`([^`]+)`", r"\1", text) - # Remove code blocks ```code``` + # Удаление блоков кода ```code``` text = re.sub(r"```[\s\S]*?```", "", text) - # Remove markdown list markers (-, *, +, numbered) + # Удаление маркеров списков (-, *, 1.) text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE) text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE) - # Remove blockquotes + # Удаление цитат > text = re.sub(r"^\s*>\s*", "", text, flags=re.MULTILINE) - # Remove horizontal rules + # Удаление горизонтальных линий --- text = re.sub(r"^[-*_]{3,}\s*$", "", text, flags=re.MULTILINE) - # Remove HTML tags if any + # Удаление HTML тегов text = re.sub(r"<[^>]+>", "", text) # Remove informal slang greetings at the beginning of sentences/responses @@ -282,7 +276,4 @@ def clean_response(text: str, language: str = "ru") -> str: text = re.sub(r"\n{3,}", "\n\n", text) text = re.sub(r" +", " ", text) - # Clean up and return - text = text.strip() - - return text + return text.strip() diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..2f743e9 --- /dev/null +++ b/app/core/config.py @@ -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 diff --git a/app/features/__init__.py b/app/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alarm.py b/app/features/alarm.py similarity index 52% rename from alarm.py rename to app/features/alarm.py index a710304..2c55768 100644 --- a/alarm.py +++ b/app/features/alarm.py @@ -1,19 +1,21 @@ -""" -Alarm clock module. -Handles alarm scheduling, persistence, and playback. -""" +"""Alarm clock module.""" + +# Модуль будильника. +# Отвечает за хранение будильников (в JSON файле), их проверку и воспроизведение звука. + import json -import time import subprocess import re -import threading from datetime import datetime from pathlib import Path -from config import BASE_DIR -from local_stt import listen_for_keywords +from ..core.config import BASE_DIR +from ..audio.local_stt import listen_for_keywords + +# Файл базы данных будильников +ALARM_FILE = BASE_DIR / "data" / "alarms.json" +# Звуковой файл сигнала +ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3" -ALARM_FILE = BASE_DIR / "alarms.json" -ALARM_SOUND = BASE_DIR / "Apex-1.mp3" class AlarmClock: def __init__(self): @@ -21,7 +23,7 @@ class AlarmClock: self.load_alarms() def load_alarms(self): - """Load alarms from JSON file.""" + """Загрузка списка будильников из JSON файла.""" if ALARM_FILE.exists(): try: with open(ALARM_FILE, "r", encoding="utf-8") as f: @@ -31,7 +33,7 @@ class AlarmClock: self.alarms = [] def save_alarms(self): - """Save alarms to JSON file.""" + """Сохранение списка будильников в JSON файл.""" try: with open(ALARM_FILE, "w", encoding="utf-8") as f: json.dump(self.alarms, f, indent=4) @@ -39,86 +41,92 @@ class AlarmClock: print(f"❌ Ошибка сохранения будильников: {e}") def add_alarm(self, hour: int, minute: int): - """Add a new alarm.""" - # Check if already exists + """Добавление нового будильника (или обновление существующего).""" for alarm in self.alarms: if alarm["hour"] == hour and alarm["minute"] == minute: alarm["active"] = True self.save_alarms() return - - self.alarms.append({ - "hour": hour, - "minute": minute, - "active": True - }) + + self.alarms.append({"hour": hour, "minute": minute, "active": True}) self.save_alarms() print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}") def cancel_all_alarms(self): - """Cancel all active alarms.""" + """Выключение (деактивация) всех будильников.""" for alarm in self.alarms: alarm["active"] = False self.save_alarms() print("🔕 Все будильники отменены.") def check_alarms(self): - """Check if any alarm should trigger now. Returns True if triggered.""" + """ + Проверка: не пора ли звенеть? + Вызывается в главном цикле. + Возвращает True, если будильник сработал. + """ now = datetime.now() triggered = False - + for alarm in self.alarms: if alarm["active"]: if alarm["hour"] == now.hour and alarm["minute"] == now.minute: - # Prevent re-triggering within the same minute? - # We should disable it immediately or track last trigger time. - # For simple logic: disable it (one-time alarm). - - # But wait, checking every second? - # If I disable it, it won't ring for the whole minute. - # Correct. - print(f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}") - alarm["active"] = False + print( + f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}" + ) + alarm["active"] = ( + False # Одноразовый будильник, выключаем после срабатывания + ) triggered = True - self.trigger_alarm() - break # Trigger one at a time - + self.trigger_alarm() # Запуск звука и ожидание стоп-слова + break # Звоним только один за раз + if triggered: self.save_alarms() return True return False def trigger_alarm(self): - """Play alarm sound and wait for stop command.""" + """ + Логика срабатывания будильника. + Запускает воспроизведение MP3 через mpg123 и слушает команду "Стоп". + Использует локальное распознавание (Vosk), чтобы не зависеть от интернета. + """ print("🔔 БУДИЛЬНИК ЗВОНИТ! (Скажите 'Стоп' или 'Александр стоп')") - - # Start playing sound in loop - # -q for quiet (no output) - # --loop -1 for infinite loop + + # Запуск плеера mpg123 в бесконечном цикле (--loop -1) cmd = ["mpg123", "-q", "--loop", "-1", str(ALARM_SOUND)] - + try: process = subprocess.Popen(cmd) except FileNotFoundError: - print("❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123") + print( + "❌ Ошибка: mpg123 не найден. Установите его: sudo apt install mpg123" + ) return try: - # Listen for stop command using local Vosk - # Loop until stop word is heard - stop_words = ["стоп", "хватит", "тихо", "замолчи", "отмена", "александр стоп"] - + stop_words = [ + "стоп", + "хватит", + "тихо", + "замолчи", + "отмена", + "александр стоп", + ] + + # Цикл ожидания стоп-команды while True: - # Listen in short bursts to be responsive + # Слушаем локально (без интернета) text = listen_for_keywords(stop_words, timeout=3.0) if text: print(f"🛑 Будильник остановлен по команде: '{text}'") break - + except Exception as e: print(f"❌ Ошибка во время будильника: {e}") finally: - # Kill the player + # Обязательно убиваем процесс плеера process.terminate() try: process.wait(timeout=1) @@ -128,65 +136,53 @@ class AlarmClock: def parse_command(self, text: str) -> str | None: """ - Parse user text for alarm commands. - Returns response string if command handled, None otherwise. + Парсинг команды установки будильника из текста. + Примеры: "разбуди в 7:30", "будильник на 8 утра". """ text = text.lower() if "будильник" not in text and "разбуди" not in text: return None - + if "отмени" in text: self.cancel_all_alarms() return "Хорошо, я отменил все будильники." - # Regex to find time: HH:MM, HH-MM, HH MM, HH часов MM минут - # 1. "07:30", "7:30" - match = re.search(r'\b(\d{1,2})[:.-](\d{2})\b', text) + # Поиск формата "7:30", "7.30" + match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text) if match: h, m = int(match.group(1)), int(match.group(2)) if 0 <= h <= 23 and 0 <= m <= 59: self.add_alarm(h, m) return f"Я установил будильник на {h} часов {m} минут." - # 2. "7 часов 30 минут" or "7 30" - # Search for pattern: digits ... (digits)? - # Complex to separate from other numbers. - - # Simple heuristics: - words = text.split() - nums = [int(s) for s in text.split() if s.isdigit()] - - # "на 7" -> 7:00 - if "на" in words or "в" in words: - # Try to find number after preposition - pass - - # Let's rely on explicit digit search if regex failed - # Patterns: "на 8", "на 8 30", "на 8 часов 30 минут", "на 8 часов" - - # Regex to capture hour and optional minute - # Matches: "на [часов] [M] [минут]" - match_time = re.search(r'на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?', text) - + # Поиск формата словами "на 7 часов 15 минут" + match_time = re.search( + r"на\s+(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?", + text, + ) + if match_time: h = int(match_time.group(1)) m = int(match_time.group(2)) if match_time.group(2) else 0 - - # Handle AM/PM if specified + + # Умная коррекция времени (если говорят "в 8", а сейчас 9, то это скорее 8 вечера или 8 утра завтра) + # Здесь простая логика AM/PM if "вечера" in text and h < 12: h += 12 elif "утра" in text and h == 12: h = 0 - + if 0 <= h <= 23 and 0 <= m <= 59: self.add_alarm(h, m) return f"Хорошо, разбужу вас в {h}:{m:02d}." return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'." -# Global instance + +# Глобальный экземпляр _alarm_clock = None + def get_alarm_clock(): global _alarm_clock if _alarm_clock is None: diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0272409 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/Alexandr_en_linux_v4_0_0.ppn b/assets/models/Alexandr_en_linux_v4_0_0.ppn similarity index 100% rename from Alexandr_en_linux_v4_0_0.ppn rename to assets/models/Alexandr_en_linux_v4_0_0.ppn diff --git a/Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn b/assets/models/Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn similarity index 100% rename from Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn rename to assets/models/Alexandr_en_linux_v4_0_0/Alexandr_en_linux_v4_0_0.ppn diff --git a/Alexandr_en_linux_v4_0_0/LICENSE.txt b/assets/models/Alexandr_en_linux_v4_0_0/LICENSE.txt similarity index 100% rename from Alexandr_en_linux_v4_0_0/LICENSE.txt rename to assets/models/Alexandr_en_linux_v4_0_0/LICENSE.txt diff --git a/Apex-1.mp3 b/assets/sounds/Apex-1.mp3 similarity index 100% rename from Apex-1.mp3 rename to assets/sounds/Apex-1.mp3 diff --git a/config.py b/config.py deleted file mode 100644 index 1b083f6..0000000 --- a/config.py +++ /dev/null @@ -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 diff --git a/data/alarms.json b/data/alarms.json new file mode 100644 index 0000000..b53d213 --- /dev/null +++ b/data/alarms.json @@ -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 + } +] \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index a45a1f1..0000000 --- a/main.py +++ /dev/null @@ -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() diff --git a/run.py b/run.py new file mode 100644 index 0000000..5472421 --- /dev/null +++ b/run.py @@ -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) diff --git a/sound_level.py b/sound_level.py deleted file mode 100644 index 9a20ac1..0000000 --- a/sound_level.py +++ /dev/null @@ -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 diff --git a/stt.py b/stt.py deleted file mode 100644 index 90ce1dc..0000000 --- a/stt.py +++ /dev/null @@ -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 diff --git a/test_cleaner.py b/tests/test_cleaner.py similarity index 77% rename from test_cleaner.py rename to tests/test_cleaner.py index ee2869b..84aeb69 100644 --- a/test_cleaner.py +++ b/tests/test_cleaner.py @@ -1,5 +1,5 @@ - -import cleaner +# Простые проверки для модуля cleaner (запуск вручную). +from app.core import cleaner import traceback try: @@ -7,11 +7,11 @@ try: text = "В 1999 году." res = cleaner.clean_response(text) print(f"Result: {res}") - + text = "![image](http://example.com)" res = cleaner.clean_response(text) print(f"Result: {res}") - + text = "[link](http://example.com)" res = cleaner.clean_response(text) print(f"Result: {res}") diff --git a/tts.py b/tts.py deleted file mode 100644 index 6a89276..0000000 --- a/tts.py +++ /dev/null @@ -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() diff --git a/wakeword.py b/wakeword.py deleted file mode 100644 index 2cf7b04..0000000 --- a/wakeword.py +++ /dev/null @@ -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()