From fd373d83f304c2efbfcba902b27cf222d4a7f6b9 Mon Sep 17 00:00:00 2001 From: nvfuture Date: Fri, 9 Jan 2026 19:59:31 +0300 Subject: [PATCH] =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=BE=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/audio/local_stt.py | 2 +- app/audio/stt.py | 59 +++++++++++++---- app/audio/wakeword.py | 7 +- app/core/ai.py | 57 +++++++++++++++++ app/core/audio_manager.py | 27 ++++++++ app/core/cleaner.py | 56 ++++++++++++++-- app/main.py | 131 +++++++++++++++++++++++++++++++------- assets/sounds/ding.wav | Bin 0 -> 13274 bytes requirements.txt | 1 + scripts/generate_ding.py | 28 ++++++++ 10 files changed, 322 insertions(+), 46 deletions(-) create mode 100644 app/core/audio_manager.py create mode 100644 assets/sounds/ding.wav create mode 100644 scripts/generate_ding.py diff --git a/app/audio/local_stt.py b/app/audio/local_stt.py index 151b0e7..516fd53 100644 --- a/app/audio/local_stt.py +++ b/app/audio/local_stt.py @@ -13,7 +13,7 @@ import sys import json import pyaudio from vosk import Model, KaldiRecognizer -from ..core.config import VOSK_MODEL_PATH, SAMPLE_RATE +from config import VOSK_MODEL_PATH, SAMPLE_RATE class LocalRecognizer: diff --git a/app/audio/stt.py b/app/audio/stt.py index 8aacc5a..c7f41a9 100644 --- a/app/audio/stt.py +++ b/app/audio/stt.py @@ -20,6 +20,7 @@ from deepgram import ( ) import deepgram.clients.common.v1.abstract_sync_websocket as sdk_ws import websockets.sync.client +from ..core.audio_manager import get_audio_manager # --- Патч (исправление) для библиотеки websockets --- # По умолчанию Deepgram SDK использует слишком короткий таймаут подключения. @@ -63,7 +64,7 @@ class SpeechRecognizer: ) self.dg_client = DeepgramClient(DEEPGRAM_API_KEY, config) - self.pa = pyaudio.PyAudio() + self.pa = get_audio_manager().get_pyaudio() print("✅ Deepgram клиент готов") def _get_stream(self): @@ -135,38 +136,71 @@ class SpeechRecognizer: channels=1, sample_rate=SAMPLE_RATE, interim_results=True, - utterance_end_ms=1200, # Пауза 1.2с считается концом фразы + utterance_end_ms=1000, # Пауза 1.0с считается концом фразы (было 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 + audio_buffer = [] # Буфер для накопления звука во время подключения + try: + # 1. Сразу начинаем захват звука, не дожидаясь сети! stream.start_stream() - print("🎤 Stream started, sending audio...") + print("🎤 Stream started (buffering)...") + + # 2. Запускаем подключение к Deepgram в фоне (через ThreadPool, т.к. start() блокирующий) + # Но в данном SDK start() возвращает bool, он может быть блокирующим. + # Deepgram Python SDK v3+ start() делает handshake. + + connect_future = loop.run_in_executor( + None, lambda: dg_connection.start(options) + ) + + # Пока подключаемся, копим данные + while not connect_future.done(): + if stream.is_active(): + data = stream.read(4096, exception_on_overflow=False) + audio_buffer.append(data) + await asyncio.sleep(0.001) + + # Проверяем результат подключения + if connect_future.result() is False: + print("Failed to start Deepgram connection") + return + + print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...") + + # 3. Отправляем накопленный буфер + for chunk in audio_buffer: + dg_connection.send(chunk) + chunks_sent += 1 + + audio_buffer = None # Освобождаем память + + # 4. Продолжаем стримить в реальном времени 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() + if stream.is_active(): + stream.stop_stream() print(f"\n🛑 Stream stopped. Chunks sent: {chunks_sent}") sender_task = asyncio.create_task(send_audio()) + if False: # dg_connection.start(options) перенесен внутрь send_audio + pass + try: # 1. Ждем начала речи (если задан detection_timeout) if detection_timeout: @@ -254,8 +288,7 @@ class SpeechRecognizer: self.stream.stop_stream() self.stream.close() self.stream = None - if self.pa: - self.pa.terminate() + # self.pa.terminate() - Используем общий менеджер # Глобальный экземпляр diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py index b194f07..5b38b74 100644 --- a/app/audio/wakeword.py +++ b/app/audio/wakeword.py @@ -10,6 +10,7 @@ import pvporcupine import pyaudio import struct from ..core.config import PORCUPINE_ACCESS_KEY, PORCUPINE_KEYWORD_PATH +from ..core.audio_manager import get_audio_manager class WakeWordDetector: @@ -28,7 +29,8 @@ class WakeWordDetector: access_key=PORCUPINE_ACCESS_KEY, keyword_paths=[str(PORCUPINE_KEYWORD_PATH)] ) - self.pa = pyaudio.PyAudio() + # Используем общий экземпляр PyAudio + self.pa = get_audio_manager().get_pyaudio() self._open_stream() print("🎤 Ожидание wake word 'Alexandr'...") @@ -138,8 +140,7 @@ class WakeWordDetector: def cleanup(self): """Освобождение ресурсов при выходе.""" self.stop_monitoring() - if self.pa: - self.pa.terminate() + # self.pa.terminate() - Не делаем этого, так как PyAudio общий if self.porcupine: self.porcupine.delete() diff --git a/app/core/ai.py b/app/core/ai.py index 0fd393c..ef80a48 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -93,6 +93,63 @@ def ask_ai(messages_history: list) -> str: return response +def ask_ai_stream(messages_history: list): + """ + Generator that yields chunks of the AI response as they arrive. + """ + if not messages_history: + yield "Извините, я не расслышал вашу команду." + return + + # Log the last user message + last_user_message = "Unknown" + for msg in reversed(messages_history): + if msg["role"] == "user": + last_user_message = msg["content"] + break + print(f"🤖 Запрос к AI (Stream): {last_user_message}") + + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + list(messages_history) + + headers = { + "Authorization": f"Bearer {PERPLEXITY_API_KEY}", + "Content-Type": "application/json", + } + payload = { + "model": PERPLEXITY_MODEL, + "messages": messages, + "max_tokens": 500, + "temperature": 1.0, + "stream": True, # Enable streaming + } + + try: + response = requests.post( + PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30, stream=True + ) + response.raise_for_status() + + import json + + for line in response.iter_lines(): + if line: + line_text = line.decode("utf-8") + if line_text.startswith("data: "): + data_str = line_text[6:] # Skip "data: " + if data_str == "[DONE]": + break + try: + data_json = json.loads(data_str) + content = data_json["choices"][0]["delta"].get("content", "") + if content: + yield content + except json.JSONDecodeError: + continue + except Exception as e: + print(f"❌ Streaming Error: {e}") + yield "Произошла ошибка связи." + + def translate_text(text: str, source_lang: str, target_lang: str) -> str: """ Запрос к AI в режиме перевода. diff --git a/app/core/audio_manager.py b/app/core/audio_manager.py new file mode 100644 index 0000000..df89dff --- /dev/null +++ b/app/core/audio_manager.py @@ -0,0 +1,27 @@ +import pyaudio +import threading + + +class AudioManager: + _instance = None + _lock = threading.Lock() + + def __new__(cls): + with cls._lock: + if cls._instance is None: + cls._instance = super(AudioManager, cls).__new__(cls) + cls._instance.pa = pyaudio.PyAudio() + print("🔊 AudioManager: PyAudio initialized (Global)") + return cls._instance + + def get_pyaudio(self): + return self.pa + + def cleanup(self): + if self.pa: + self.pa.terminate() + self.pa = None + + +def get_audio_manager(): + return AudioManager() diff --git a/app/core/cleaner.py b/app/core/cleaner.py index 06f1936..282ee59 100644 --- a/app/core/cleaner.py +++ b/app/core/cleaner.py @@ -44,6 +44,14 @@ PREPOSITION_CASES = { "перед": "ablt", "за": "ablt", "между": "ablt", + "около": "gent", + "против": "gent", + "вместо": "gent", + "кроме": "gent", + "из-за": "gent", + "сквозь": "accs", + "через": "accs", + "про": "accs", } # Соответствие падежей pymorphy и библиотеки num2words @@ -60,6 +68,13 @@ PYMORPHY_TO_NUM2WORDS = { "loc2": "prepositional", } +# Соответствие родов pymorphy и num2words +PYMORPHY_TO_GENDER = { + "masc": "m", + "femn": "f", + "neut": "n", +} + # Названия месяцев в родительном падеже (для поиска дат в тексте) MONTHS_GENITIVE = [ "января", @@ -123,6 +138,12 @@ def numbers_to_words(text: str) -> str: nw_case = PYMORPHY_TO_NUM2WORDS.get(case_tag, "nominative") + # FIX: Pymorphy часто определяет "год" как accs (винительный), что для num2words + # превращается в родительный (для одушевленных?), давая "2024 года". + # Если предлога нет, принудительно ставим именительный. + if not prep and year_word.lower().startswith("год"): + nw_case = "nominative" + # Конвертируем число в порядковое числительное (тысяча девятьсот девяносто девятом) words = convert_number( year_str, context_type="ordinal", case=nw_case, gender="m" @@ -171,9 +192,9 @@ def numbers_to_words(text: str) -> str: prefix = f"{prep} " if prep else "" return f"{prefix}{words} {month_word}" - # Конкатенация regex для месяцев (ВАЖНО: month_regex должен быть вставлен в строку) + # Конкатенация regex для месяцев (FIX: используем f-строку) text = re.sub( - r"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{1,2})\s+({month_regex})\b", + rf"(?i)\b((?:с|к|до|от|на|по)\s+)?(\d{{1,2}})\s+({month_regex})\b", replace_date_match, text, ) @@ -182,20 +203,41 @@ def numbers_to_words(text: str) -> str: def replace_cardinal_match(match): prep = match.group(1) num_str = match.group(2) + next_word = match.group(3) case = "nominative" + gender = "m" + if prep: morph_case = get_case_from_preposition(prep.strip()) if morph_case: case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative") - words = convert_number(num_str, context_type="cardinal", case=case) + # Если есть следующее слово, проверяем его род (для "2 минуты" -> "две") + if next_word: + word_clean = next_word.strip() + parsed = morph.parse(word_clean)[0] + if "NOUN" in parsed.tag: + morph_gender = parsed.tag.gender + gender = PYMORPHY_TO_GENDER.get(morph_gender, "m") + + words = convert_number( + num_str, context_type="cardinal", case=case, gender=gender + ) + + # Если конвертация вернула пустую строку (сбой?), возвращаем цифры + if not words: + words = num_str prefix = f"{prep} " if prep else "" + # suffix removed (lookahead) return f"{prefix}{words}" + # Регулярка теперь захватывает (опционально) следующее слово для определения рода + + preps_list = "|".join(map(re.escape, PREPOSITION_CASES.keys())) text = re.sub( - r"(?i)\b((?:в|на|о|об|обо|при|у|от|до|из|с|со|без|для|вокруг|после|к|ко|по|над|под|перед|за|между)\s+)?(\d+(?:[.,]\d+)?)\b", + rf"(?i)\b((?:{preps_list})\s+)?(\d+(?:[.,]\d+)?)(?=(\s+[а-яА-ЯёЁ]+))?\b", replace_cardinal_match, text, ) @@ -234,13 +276,13 @@ def clean_response(text: str, language: str = "ru") -> str: # Удаление заголовков Markdown (# Header) text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE) + # Удаление картинок ![alt](url) -> удаляем полностью + text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text) + # Удаление ссылок [text](url) -> оставляем только text # \x5B = [, \x5D = ] text = re.sub(r"\x5B([^\x5D]+)\x5D\([^)]+\)", r"\1", text) - # Удаление картинок ![alt](url) -> удаляем полностью - text = re.sub(r"!\x5B([^\x5D]*)\x5D\([^)]+\)", "", text) - # Удаление inline кода `code` text = re.sub(r"`([^`]+)`", r"\1", text) diff --git a/app/main.py b/app/main.py index 0272409..31f48b9 100644 --- a/app/main.py +++ b/app/main.py @@ -16,8 +16,15 @@ Flow: import signal import sys +import threading +import queue +import re +import os from collections import deque +# Для воспроизведения звуков (mp3) +from pygame import mixer + # Импорт наших модулей from .audio.wakeword import ( wait_for_wakeword, @@ -26,7 +33,7 @@ from .audio.wakeword import ( 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.ai import ask_ai, ask_ai_stream, 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 @@ -124,8 +131,19 @@ def main(): # Устанавливаем перехватчик Ctrl+C signal.signal(signal.SIGINT, signal_handler) - # Предварительная инициализация моделей (занимает пару секунд при старте) + # Предварительная инициализация моделей print("⏳ Инициализация моделей...") + + # Инициализация звуковой системы для эффектов + mixer.init() + ding_sound_path = "assets/sounds/ding.wav" + ding_sound = None + if os.path.exists(ding_sound_path): + ding_sound = mixer.Sound(ding_sound_path) + ding_sound.set_volume(0.3) + else: + print(f"⚠️ Звук {ding_sound_path} не найден") + get_recognizer().initialize() # Подключение к Deepgram init_tts() # Загрузка нейросети для синтеза речи (Silero) alarm_clock = get_alarm_clock() # Загрузка будильников @@ -163,6 +181,10 @@ def main(): if not detected: continue + # Воспроизводим звук активации + if ding_sound: + ding_sound.play() + # Фраза услышана! Слушаем команду пользователя (7 секунд тишины макс) user_text = listen(timeout_seconds=7.0) else: @@ -205,7 +227,8 @@ def main(): ] # Проверяем точное совпадение или если фраза начинается с "повтори" (но не "повтори за мной") if user_text_lower in repeat_phrases or ( - user_text_lower.startswith("повтори") and "за мной" not in user_text_lower + user_text_lower.startswith("повтори") + and "за мной" not in user_text_lower ): if last_response: print(f"🔁 Повторяю: {last_response}") @@ -296,39 +319,103 @@ def main(): print("⏹️ Перевод прерван - слушаю следующий вопрос") continue - # --- Шаг 3: Запрос к AI (обычный чат) --- + # --- Шаг 3: Запрос к AI (Streaming) --- # Добавляем сообщение пользователя в историю chat_history.append({"role": "user", "content": user_text}) - # Отправляем историю диалога в Perplexity - ai_response = ask_ai(list(chat_history)) + # Очередь для предложений, которые нужно озвучить + tts_q = queue.Queue() + # Флаг прерывания для worker-а + interrupt_event = threading.Event() - # Добавляем ответ AI в историю - chat_history.append({"role": "assistant", "content": ai_response}) + def tts_worker(): + """Фоновый поток, читающий предложения из очереди и озвучивающий их.""" + while True: + item = tts_q.get() + if item is None: # Poison pill (сигнал остановки) + tts_q.task_done() + break - # --- Шаг 4: Очистка ответа --- - # Убираем Markdown (**жирный**, *курсив*) и готовим числа для озвучки - clean_text = clean_response(ai_response, language="ru") + text, lang = item - # Сохраняем последний ответ для функции "еще раз" - last_response = clean_text + # Если уже было прерывание, просто пропускаем (чистим очередь) + if interrupt_event.is_set(): + tts_q.task_done() + continue - # --- Шаг 5: Озвучка ответа --- - # check_interrupt=check_wakeword_once позволяет прервать речь, сказав "Alexandr" - completed = speak( - clean_text, check_interrupt=check_wakeword_once, language="ru" - ) + # Озвучиваем + completed = speak( + text, check_interrupt=check_wakeword_once, language=lang + ) - # После озвучки обязательно закрываем поток микрофона, который открывался для проверки прерывания + if not completed: + interrupt_event.set() # Сообщаем всем, что нас перебили + + tts_q.task_done() + + # Запускаем поток озвучки + worker_thread = threading.Thread(target=tts_worker, daemon=True) + worker_thread.start() + + full_response = "" + buffer = "" + + try: + # Получаем генератор потока от AI + stream_generator = ask_ai_stream(list(chat_history)) + + print("🤖 AI говорит: ", end="", flush=True) + + for chunk in stream_generator: + # Если в процессе генерации нас перебили (на ранних фразах), прерываем получение + if interrupt_event.is_set(): + break + + buffer += chunk + full_response += chunk + print(chunk, end="", flush=True) + + # Проверяем на конец предложения (. ! ? + пробел или конец строки) + # Эвристика: ищем знаки препинания, после которых идет пробел или перевод строки + if re.search(r"[.!?\n]+(?:\s|$)", buffer): + # Очищаем и отправляем в очередь + clean_chunk = clean_response(buffer, language="ru") + if clean_chunk.strip(): + tts_q.put((clean_chunk, "ru")) + buffer = "" + + # Отправляем остаток (если есть) + if buffer.strip() and not interrupt_event.is_set(): + clean_chunk = clean_response(buffer, language="ru") + if clean_chunk.strip(): + tts_q.put((clean_chunk, "ru")) + + except Exception as e: + print(f"\n❌ Ошибка стриминга: {e}") + speak("Произошла ошибка при получении ответа.") + + # Ждем, пока все договорится + # Добавляем poison pill, чтобы поток завершился, когда очередь пуста + tts_q.put(None) + worker_thread.join() + + print() # Перенос строки после вывода AI + + # Добавляем полный ответ AI в историю + chat_history.append({"role": "assistant", "content": full_response}) + + # Сохраняем для "повтори" + last_response = clean_response(full_response, language="ru") + + # После озвучки обязательно закрываем поток микрофона stop_wakeword_monitoring() # Включаем режим диалога (следующий запрос можно говорить без имени) skip_wakeword = True - if not completed: + if interrupt_event.is_set(): print("⏹️ Ответ прерван - слушаю следующий вопрос") - # Если перебили, значит есть новый вопрос, сразу слушаем его (цикл перезапустится) - pass + # Если перебили, цикл перезапустится и skip_wakeword уже True print() print("-" * 30) diff --git a/assets/sounds/ding.wav b/assets/sounds/ding.wav new file mode 100644 index 0000000000000000000000000000000000000000..451843fecc157ac4ac7c52c01dec719ae0268bc3 GIT binary patch literal 13274 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