From 845ef7c531f515548a71671b7483a5eff89ed440 Mon Sep 17 00:00:00 2001 From: future Date: Mon, 2 Feb 2026 21:06:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=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=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B3=D0=BE=D0=B4=D1=8B=20+=20=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20+=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BE=D1=81=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BF=D0=B0=D1=80=D1=8B=20=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Qwen-Coder --- app/audio/sound_level.py | 84 ++++++++- app/audio/stt.py | 137 +++++++++++--- app/audio/tts.py | 2 +- app/audio/wakeword.py | 2 +- app/core/ai.py | 5 +- app/features/weather.py | 391 ++++++++++++++++++++++++++++++++++++--- app/main.py | 112 +++++++++-- 7 files changed, 661 insertions(+), 72 deletions(-) diff --git a/app/audio/sound_level.py b/app/audio/sound_level.py index 08f70b0..1ccd624 100644 --- a/app/audio/sound_level.py +++ b/app/audio/sound_level.py @@ -4,10 +4,11 @@ Regulates system volume on a scale from 1 to 10. """ # Модуль управления громкостью системы. -# Работает через системную утилиту amixer (ALSA) в Linux. +# Работает через различные системные утилиты в зависимости от ОС. import subprocess import re +import platform # Карта для перевода слов в цифры ("пять" -> 5) NUMBER_MAP = { @@ -25,6 +26,71 @@ NUMBER_MAP = { } +def _get_volume_command(level: int): + """ + Возвращает команду для изменения громкости в зависимости от ОС. + + Args: + level: Уровень громкости (1-10) + + Returns: + Список команд для выполнения или None, если команда не поддерживается + """ + percentage = level * 10 + + system = platform.system().lower() + + if system == "linux": + # Проверяем доступность различных утилит + if _command_exists("pactl"): + # Используем PulseAudio (более современный подход) + return ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percentage}%"] + elif _command_exists("amixer"): + # Используем ALSA + return ["amixer", "-q", "sset", "Master", f"{percentage}%"] + else: + # Проверяем alsamixer + if _command_exists("alsamixer"): + return ["amixer", "-q", "sset", "Master", f"{percentage}%"] + elif system == "darwin": # macOS + return ["osascript", "-e", f"set volume output volume {percentage}"] + elif system == "windows": + # Для Windows используем PowerShell команду + # Это требует дополнительных библиотек, поэтому пока просто покажем сообщение + print("⚠️ Настройка громкости на Windows требует дополнительных библиотек") + return None + + return None + + +def _command_exists(command): + """ + Проверяет, существует ли команда в системе. + + Args: + command: Название команды + + Returns: + True, если команда существует + """ + try: + subprocess.run(["which", command], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False) + return True + except: + try: + # Альтернативная проверка для Windows + subprocess.run(["where", command], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False) + return True + except: + return False + + def set_volume(level: int) -> bool: """ Устанавливает системную громкость (шкала 1-10). @@ -51,16 +117,22 @@ def set_volume(level: int) -> bool: percentage = level * 10 + # Получаем команду для текущей ОС + cmd = _get_volume_command(level) + + if cmd is None: + print(f"❌ Не найдена подходящая утилита для изменения громкости на вашей системе") + print(f"💡 Установите PulseAudio (pactl) или ALSA (amixer) для управления громкостью") + return False + try: - # Вызов команды amixer для изменения громкости Master канала - # -q: quiet (без вывода) - # sset: simple set - cmd = ["amixer", "-q", "sset", "Master", f"{percentage}%"] - subprocess.run(cmd, check=True) + # Выполняем команду + result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"🔊 Громкость установлена на {level} ({percentage}%)") return True except subprocess.CalledProcessError as e: print(f"❌ Ошибка при установке громкости: {e}") + print(f"💡 Убедитесь, что у вас установлены и настроены аудио утилиты (pactl, amixer)") return False except Exception as e: print(f"❌ Неизвестная ошибка громкости: {e}") diff --git a/app/audio/stt.py b/app/audio/stt.py index 2517895..7523f42 100644 --- a/app/audio/stt.py +++ b/app/audio/stt.py @@ -11,6 +11,7 @@ import asyncio import time import pyaudio import logging +from datetime import datetime, timedelta from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE from deepgram import ( DeepgramClient, @@ -52,6 +53,7 @@ class SpeechRecognizer: self.pa = None self.stream = None self.transcript = "" + self.last_successful_operation = datetime.now() def initialize(self): """Инициализация клиента Deepgram и PyAudio.""" @@ -62,10 +64,34 @@ class SpeechRecognizer: config = DeepgramClientOptions( verbose=logging.WARNING, ) - self.dg_client = DeepgramClient(DEEPGRAM_API_KEY, config) + try: + self.dg_client = DeepgramClient(DEEPGRAM_API_KEY, config) + except Exception as e: + print(f"❌ Ошибка при создании клиента Deepgram: {e}") + raise self.pa = get_audio_manager().get_pyaudio() print("✅ Deepgram клиент готов") + # Обновляем время последней успешной операции + self.last_successful_operation = datetime.now() + + def check_connection_health(self): + """Проверяет здоровье соединения и при необходимости пересоздает клиента.""" + # Проверяем, прошло ли больше 15 минут с последней успешной операции + if datetime.now() - self.last_successful_operation > timedelta(minutes=15): + print("🔄 Обновление соединения Deepgram для предотвращения таймаута...") + try: + # Очищаем старый клиент + if self.stream: + self.stream.stop_stream() + self.stream.close() + self.stream = None + # Создаем новый клиент + self.dg_client = None + self.initialize() + print("✅ Соединение Deepgram обновлено") + except Exception as e: + print(f"⚠️ Ошибка при обновлении соединения: {e}") def _get_stream(self): """Открывает аудиопоток PyAudio, если он еще не открыт.""" @@ -111,15 +137,27 @@ class SpeechRecognizer: def on_speech_started(unused_self, speech_started, **kwargs): """Вызывается, когда VAD (Voice Activity Detection) слышит голос.""" - loop.call_soon_threadsafe(speech_started_event.set) + try: + loop.call_soon_threadsafe(speech_started_event.set) + except RuntimeError: + # Event loop might be closed, ignore + pass def on_utterance_end(unused_self, utterance_end, **kwargs): """Вызывается, когда Deepgram решает, что фраза закончилась (пауза).""" - loop.call_soon_threadsafe(stop_event.set) + try: + loop.call_soon_threadsafe(stop_event.set) + except RuntimeError: + # Event loop might be closed, ignore + pass def on_error(unused_self, error, **kwargs): - print(f"Error: {error}") - loop.call_soon_threadsafe(stop_event.set) + print(f"Deepgram Error: {error}") + try: + loop.call_soon_threadsafe(stop_event.set) + except RuntimeError: + # Event loop might be closed, ignore + pass # Подписываемся на события dg_connection.on(LiveTranscriptionEvents.Transcript, on_transcript) @@ -138,6 +176,8 @@ class SpeechRecognizer: interim_results=True, utterance_end_ms=1000, # Пауза 1.0с считается концом фразы (было 1.2) vad_events=True, + # Добавляем параметры таймаута для долгой работы + endpointing=300, # Таймаут в миллисекундах для автоматического завершения ) # --- Задача отправки аудио с буферизацией --- @@ -159,11 +199,19 @@ class SpeechRecognizer: ) # Пока подключаемся, копим данные - while not connect_future.done(): + timeout_count = 0 + max_timeout = 5000 # Максимальное количество итераций ожидания (около 2.5 секунд при 0.0005 задержке) + + while not connect_future.done() and timeout_count < max_timeout: if stream.is_active(): data = stream.read(4096, exception_on_overflow=False) audio_buffer.append(data) - await asyncio.sleep(0.001) + await asyncio.sleep(0.0005) # Уменьшаем задержку для более быстрой обработки + timeout_count += 1 + + if timeout_count >= max_timeout: + print("⏰ Timeout connecting to Deepgram") + return # Проверяем результат подключения if connect_future.result() is False: @@ -180,14 +228,18 @@ class SpeechRecognizer: audio_buffer = None # Освобождаем память # 4. Продолжаем стримить в реальном времени - while not stop_event.is_set(): + stream_timeout = 0 + max_stream_timeout = int(timeout_seconds / 0.002) # Примерный таймаут в зависимости от timeout_seconds + + while not stop_event.is_set() and stream_timeout < max_stream_timeout: if stream.is_active(): data = stream.read(4096, exception_on_overflow=False) dg_connection.send(data) chunks_sent += 1 if chunks_sent % 50 == 0: print(".", end="", flush=True) - await asyncio.sleep(0.005) + await asyncio.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования + stream_timeout += 1 except Exception as e: print(f"Audio send error: {e}") @@ -209,7 +261,7 @@ class SpeechRecognizer: speech_started_event.wait(), timeout=detection_timeout ) except asyncio.TimeoutError: - # Если за detection_timeout (5 сек) никто не начал говорить, выходим + # Если за detection_timeout никто не начал говорить, выходим stop_event.set() # 2. Если речь началась (или таймаута нет), ждем завершения (stop_event) @@ -219,11 +271,20 @@ class SpeechRecognizer: except asyncio.TimeoutError: pass # Общий таймаут вышел + except Exception as e: + print(f"Error in waiting for events: {e}") stop_event.set() - await sender_task + try: + await sender_task + except Exception as e: + print(f"Error waiting for sender task: {e}") + # Завершаем соединение и ждем последние результаты - dg_connection.finish() + try: + dg_connection.finish() + except Exception as e: + print(f"Error finishing connection: {e}") return self.transcript @@ -244,17 +305,21 @@ class SpeechRecognizer: if not self.dg_client: self.initialize() + # Проверяем здоровье соединения перед началом прослушивания + self.check_connection_health() + 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") - + # Делаем 3 попытки на случай сбоя сети + for attempt in range(3): + dg_connection = None try: + # Создаем новое live подключение для каждой сессии + dg_connection = self.dg_client.listen.live.v("1") + # Запускаем асинхронный процесс обработки transcript = asyncio.run( self._process_audio( @@ -264,20 +329,32 @@ class SpeechRecognizer: final_text = transcript.strip() if transcript else "" if final_text: print(f"📝 Распознано: {final_text}") + # Обновляем время последней успешной операции + self.last_successful_operation = datetime.now() return final_text else: # Если вернулась пустая строка (тишина), считаем это штатным завершением. # Не нужно повторять попытку, как при ошибке сети. + # Все равно обновляем время последней успешной операции + self.last_successful_operation = datetime.now() return "" except Exception as e: last_error = e + print(f"Attempt {attempt + 1} failed: {e}") - if attempt == 0: - print("⚠️ Не удалось подключиться к Deepgram, повторяю...") - time.sleep(1) + # Закрываем соединение, если оно было создано + if dg_connection: + try: + dg_connection.finish() + except: + pass # Игнорируем ошибки при завершении + + if attempt < 2: # Не ждем после последней попытки + print(f"⚠️ Не удалось подключиться к Deepgram, попытка {attempt + 1}/3, повторяю...") + time.sleep(1) # Уменьшаем задержку между попытками if last_error: - print(f"❌ Ошибка STT: {last_error}") + print(f"❌ Ошибка STT после всех попыток: {last_error}") else: print("⚠️ Речь не распознана") return "" @@ -285,10 +362,19 @@ class SpeechRecognizer: def cleanup(self): """Очистка ресурсов.""" if self.stream: - self.stream.stop_stream() - self.stream.close() + try: + if self.stream.is_active(): + self.stream.stop_stream() + except Exception as e: + print(f"Ошибка при остановке потока: {e}") + try: + self.stream.close() + except Exception as e: + print(f"Ошибка при закрытии потока: {e}") self.stream = None # self.pa.terminate() - Используем общий менеджер + # Сбросим клиента для принудительного переподключения + self.dg_client = None # Глобальный экземпляр @@ -313,5 +399,8 @@ def cleanup(): """Внешняя функция очистки.""" global _recognizer if _recognizer: - _recognizer.cleanup() + try: + _recognizer.cleanup() + except Exception as e: + print(f"Ошибка при очистке STT: {e}") _recognizer = None diff --git a/app/audio/tts.py b/app/audio/tts.py index dfc42ae..f91325c 100644 --- a/app/audio/tts.py +++ b/app/audio/tts.py @@ -326,7 +326,7 @@ class TextToSpeech: while sd.get_stream().active: if self._interrupted: break - time.sleep(0.05) + time.sleep(0.02) # Уменьшаем задержку для более быстрого реагирования finally: # Сообщаем потоку-наблюдателю, что пора завершаться diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py index 3d4b14c..8efbcea 100644 --- a/app/audio/wakeword.py +++ b/app/audio/wakeword.py @@ -135,7 +135,7 @@ class WakeWordDetector: keyword_index = self.porcupine.process(pcm) if keyword_index >= 0: now = time.time() - if now - self._last_hit_ts < 0.4: + if now - self._last_hit_ts < 0.2: # Уменьшаем интервал для более быстрой реакции return False self._last_hit_ts = now print("🛑 Wake word обнаружен во время ответа!") diff --git a/app/core/ai.py b/app/core/ai.py index 8533b56..b43dd7b 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -47,11 +47,12 @@ def _send_request(messages, max_tokens, temperature, error_text): "messages": messages, "max_tokens": max_tokens, "temperature": temperature, + "stream": False # Убираем стриминг для более быстрого ответа } try: response = requests.post( - PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30 + PERPLEXITY_API_URL, headers=headers, json=payload, timeout=15 # Уменьшаем таймаут ) response.raise_for_status() # Проверка на ошибки HTTP (4xx, 5xx) data = response.json() @@ -129,7 +130,7 @@ def ask_ai_stream(messages_history: list): try: response = requests.post( - PERPLEXITY_API_URL, headers=headers, json=payload, timeout=30, stream=True + PERPLEXITY_API_URL, headers=headers, json=payload, timeout=15, stream=True # Уменьшаем таймаут ) response.raise_for_status() diff --git a/app/features/weather.py b/app/features/weather.py index f496a6e..36d1974 100644 --- a/app/features/weather.py +++ b/app/features/weather.py @@ -41,21 +41,360 @@ def get_wmo_description(code: int) -> str: } return codes.get(code, "осадки") -def get_weather_report() -> str: +def get_temperature_text(temp: int) -> str: + """ + Returns the correct Russian form for temperature degrees based on the number. + Handles proper Russian grammar cases (падежи) for temperature values. + """ + # Get the absolute value to handle negative temperatures + abs_temp = abs(temp) + + # Get the last digit + last_digit = abs_temp % 10 + # Get the last two digits to handle special cases like 11-14 + last_two_digits = abs_temp % 100 + + # Special cases for numbers ending in 11-14 (e.g., 11, 12, 13, 14, 111, 112, etc.) + if 11 <= last_two_digits <= 14: + return f"{temp} градусов" + + # Cases based on the last digit + if last_digit == 1: + return f"{temp} градус" + elif 2 <= last_digit <= 4: + return f"{temp} градуса" + else: # 5-9, 0 + return f"{temp} градусов" + +def normalize_city_name(city_name: str) -> str: + """ + Converts city names from various grammatical cases to the base form for geocoding. + Handles common Russian grammatical cases (падежи) for city names. + """ + # Convert to lowercase for comparison + lower_city = city_name.lower() + + # Remove common Russian location descriptors that might be included by mistake + # For example, if someone says "в городе Волгоград", the city_name might be "городе волгоград" + # So we want to extract just "волгоград" + if 'городе' in lower_city: + # Extract the part after "городе" + parts = lower_city.split('городе') + if len(parts) > 1: + lower_city = parts[1].strip() + elif 'город' in lower_city: + # Extract the part after "город" + parts = lower_city.split('город') + if len(parts) > 1: + lower_city = parts[1].strip() + + # Common endings for different cases in Russian + # Prepositional case endings (-е, -и, -у, etc.) + prepositional_endings = ['е', 'и', 'у', 'о', 'й'] + genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын'] + instrumental_endings = ['ом', 'ем', 'ой', 'ей'] + + # If the city ends with a prepositional ending, try removing it to get the base form + if lower_city.endswith(tuple(prepositional_endings)): + # Try to remove the ending and see if we get a valid base form + base_form = lower_city + # Try removing 1-2 characters to get the base form + for i in range(2, 0, -1): # Try removing 2 chars, then 1 char + if len(base_form) > i: + potential_base = base_form[:-i] + # Check if the removed part is a common ending + if base_form[-i:] in ['ке', 'ме', 'не', 'ве', 'ге', 'де', 'те']: + base_form = potential_base + break + elif base_form[-1] in prepositional_endings: + base_form = base_form[:-1] + break + + # Special handling for common patterns + if base_form.endswith('йорке'): # "нью-йорке" -> "нью-йорк" + base_form = base_form[:-1] + 'к' + elif base_form.endswith('ске'): # "москве" -> "москва", "париже" -> "париж" + # This is more complex, but for "москве" -> "москва", "париже" -> "париж" + # We'll handle the most common cases + if base_form == 'москве': + base_form = 'москва' + elif base_form == 'париже': + base_form = 'париж' + elif base_form == 'лондоне': + base_form = 'лондон' + elif base_form == 'берлине': + base_form = 'берлин' + elif base_form == 'токио': # токио stays токио + base_form = 'токио' + else: + # General rule: replace -е with -а or -ь + if base_form.endswith('ске'): + base_form = base_form[:-1] + 'а' + elif base_form.endswith('ие'): + base_form = base_form[:-2] + 'ия' + + # Capitalize appropriately + if base_form != lower_city: + return base_form.capitalize() + + # Dictionary mapping specific known variations + case_variations = { + "нью-йорке": "Нью-Йорк", + "нью-йорка": "Нью-Йорк", + "нью-йорком": "Нью-Йорк", + "москве": "Москва", + "москвы": "Москва", + "москвой": "Москва", + "москву": "Москва", + "лондоне": "Лондон", + "лондона": "Лондон", + "лондоном": "Лондон", + "париже": "Париж", + "парижа": "Париж", + "парижем": "Париж", + "берлине": "Берлин", + "берлина": "Берлин", + "берлином": "Берлин", + "пекине": "Пекин", + "пекина": "Пекин", + "пекином": "Пекин", + "роме": "Рим", + "рима": "Рим", + "римом": "Рим", + "мадриде": "Мадрид", + "мадрида": "Мадрид", + "мадридом": "Мадрид", + "сиднее": "Сидней", + "сиднея": "Сидней", + "сиднеем": "Сидней", + "вашингтоне": "Вашингтон", + "вашингтона": "Вашингтон", + "вашингтоном": "Вашингтон", + "лос-анджелесе": "Лос-Анджелес", + "лос-анджелеса": "Лос-Анджелес", + "лос-анджелесом": "Лос-Анджелес", + "сиэтле": "Сиэтл", + "сиэтла": "Сиэтл", + "сиэтлом": "Сиэтл", + "бостоне": "Бостон", + "бостона": "Бостон", + "бостоном": "Бостон", + "денвере": "Денвер", + "денвера": "Денвер", + "денвером": "Денвер", + "хьюстоне": "Хьюстон", + "хьюстона": "Хьюстон", + "хьюстоном": "Хьюстон", + "фениксе": "Феникс", + "феникса": "Феникс", + "фениксом": "Феникс", + "атланте": "Атланта", + "атланты": "Атланта", + "атлантой": "Атланта", + "портленде": "Портленд", + "портленда": "Портленд", + "портлендом": "Портленд", + "остине": "Остин", + "остина": "Остин", + "остином": "Остин", + "нэшвилле": "Нэшвилл", + "нэшвилла": "Нэшвилл", + "нэшвиллом": "Нэшвилл", + "сан-франциско": "Сан-Франциско", + "токио": "Токио", + "торонто": "Торонто", + "чикаго": "Чикаго", + "майами": "Майами", + } + + return case_variations.get(lower_city, city_name) + +def get_coordinates_by_city(city_name: str) -> tuple: + """ + Gets coordinates (lat, lon) for a given city name using Open-Meteo geocoding API. + Returns (lat, lon, city_display_name) or (None, None, None) if not found. + """ + # First try with the original name + try_names = [city_name] + + # Add normalized version + normalized_city = normalize_city_name(city_name) + if normalized_city != city_name: + try_names.append(normalized_city) + + # Also try with English version if it's a known translation + city_to_eng = { + "москва": "Moscow", + "санкт-петербург": "Saint Petersburg", + "новосибирск": "Novosibirsk", + "екатеринбург": "Yekaterinburg", + "казань": "Kazan", + "нижний новгород": "Nizhny Novgorod", + "челябинск": "Chelyabinsk", + "омск": "Omsk", + "самара": "Samara", + "ростов-на-дону": "Rostov-on-Don", + "уфа": "Ufa", + "красноярск": "Krasnoyarsk", + "владивосток": "Vladivostok", + "сочи": "Sochi", + "новокузнецк": "Novokuznetsk", + "ярославль": "Yaroslavl", + "владикавказ": "Vladikavkaz", + "магнитогорск": "Magnitogorsk", + "иркутск": "Irkutsk", + "хабаровск": "Khabarovsk", + "оренбург": "Orenburg", + "калининград": "Kaliningrad", + "пермь": "Perm", + "волгоград": "Volgograd", + "волгограде": "Volgograd", + "краснодар": "Krasnodar", + "саратов": "Saratov", + "тында": "Tynda", + "тольятти": "Tolyatti", + "барнаул": "Barnaul", + "улан-удэ": "Ulan-Ude", + "иваново": "Ivanovo", + "мурманск": "Murmansk", + "кузнецк": "Kuznetsk", + "архангельск": "Arkhangelsk", + "владимир": "Vladimir", + "калининград": "Kaliningrad", + "смоленск": "Smolensk", + "калука": "Kaluga", + "воронеж": "Voronezh", + "курск": "Kursk", + "астрахань": "Astrakhan", + "липецк": "Lipetsk", + "тамбов": "Tambov", + "курган": "Kurgan", + "пенза": "Penza", + "рязн": "Ryazan", + "орёл": "Oryol", + "якутск": "Yakutsk", + "владикавказ": "Vladikavkaz", + "магас": "Magas", + "нарьян-мар": "Naryan-Mar", + "ханты-мансийск": "Khanty-Mansiysk", + "анадырь": "Anadyr", + "салехард": "Salekhard", + "лондон": "London", + "нью-йорк": "New York", + "токио": "Tokyo", + "париж": "Paris", + "берлин": "Berlin", + "мадрид": "Madrid", + "рим": "Rome", + "милан": "Milan", + "венеция": "Venice", + "амстердам": "Amsterdam", + "прага": "Prague", + "будапешт": "Budapest", + "вена": "Vienna", + "варшава": "Warsaw", + "киев": "Kyiv", + "минск": "Minsk", + "ташкент": "Tashkent", + "алматы": "Almaty", + "астана": "Astana", + "баку": "Baku", + "ереван": "Yerevan", + "тбилиси": "Tbilisi", + "софия": "Sofia", + "белград": "Belgrade", + "любляна": "Ljubljana", + "загреб": "Zagreb", + "рекьявик": "Reykjavik", + "осло": "Oslo", + "стокгольм": "Stockholm", + "копенгаген": "Copenhagen", + "хельсинки": "Helsinki", + "дублин": "Dublin", + "эдинбург": "Edinburgh", + "манчестер": "Manchester", + "бirmingham": "Birmingham", + "ливерпуль": "Liverpool", + "глазго": "Glasgow", + "брюссель": "Brussels", + "цюрих": "Zurich", + "женева": "Geneva", + "осака": "Osaka", + "киото": "Kyoto", + "сингапур": "Singapore", + "бангкок": "Bangkok", + "пекин": "Beijing", + "шанхай": "Shanghai", + "гонконг": "Hong Kong", + "сеул": "Seoul", + "дели": "Delhi", + "мумбаи": "Mumbai", + "бомбей": "Mumbai", + } + + eng_name = city_to_eng.get(city_name.lower()) + if eng_name: + try_names.append(eng_name) + + # Try each name in sequence + for name_to_try in try_names: + try: + # Use Open-Meteo's geocoding API + geocode_url = "https://geocoding-api.open-meteo.com/v1/search" + params = { + "name": name_to_try, + "count": 1, + "language": "ru", + "format": "json" + } + + response = requests.get(geocode_url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + if "results" in data and len(data["results"]) > 0: + result = data["results"][0] + lat = result["latitude"] + lon = result["longitude"] + display_name = result.get("name", name_to_try) # Use the name from the API if available + return lat, lon, display_name + + except Exception as e: + print(f"❌ Ошибка при поиске координат для города {name_to_try}: {e}") + continue # Try the next name + + return None, None, None + + +def get_weather_report(requested_city: str = None) -> str: """ Fetches detailed weather report. Structure: 1. Current temp and precipitation. 2. Today's min/max temp. 3. Next 4 hours forecast (temp + precipitation). + + Args: + requested_city: Optional city name to get weather for. If None, uses default city. """ - if not all([WEATHER_LAT, WEATHER_LON, WEATHER_CITY]): - return "Настройки погоды не найдены. Проверьте конфигурацию." + # Determine which city to use + if requested_city: + # Try to get coordinates for the requested city + lat, lon, city_display_name = get_coordinates_by_city(requested_city) + if lat is None or lon is None: + return f"Не удалось найти город {requested_city}. Проверьте название и попробуйте снова." + else: + # Use default city from config + if not all([WEATHER_LAT, WEATHER_LON, WEATHER_CITY]): + return "Настройки погоды не найдены. Проверьте конфигурацию." + lat = float(WEATHER_LAT) + lon = float(WEATHER_LON) + city_display_name = WEATHER_CITY url = "https://api.open-meteo.com/v1/forecast" params = { - "latitude": WEATHER_LAT, - "longitude": WEATHER_LON, + "latitude": lat, + "longitude": lon, "current": "temperature_2m,precipitation,weather_code", "hourly": "temperature_2m,precipitation,weather_code", "daily": "temperature_2m_max,temperature_2m_min", @@ -64,7 +403,7 @@ def get_weather_report() -> str: } try: - response = requests.get(url, params=params, timeout=5) + response = requests.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() @@ -75,7 +414,7 @@ def get_weather_report() -> str: code_now = curr["weather_code"] desc_now = get_wmo_description(code_now) - report = f"Сейчас в городе {WEATHER_CITY} {temp_now} градусов, {desc_now}." + report = f"Сейчас в городе {city_display_name} {get_temperature_text(temp_now)}, {desc_now}." if precip_now > 0: report += f" Выпало {precip_now} миллиметров осадков." @@ -84,41 +423,33 @@ def get_weather_report() -> str: daily = data["daily"] t_max = round(daily["temperature_2m_max"][0]) t_min = round(daily["temperature_2m_min"][0]) - - report += f" Сегодня температура будет от {t_min} до {t_max} градусов." + + report += f" Сегодня температура будет от {get_temperature_text(t_min)} до {get_temperature_text(t_max)}." # --- 3. Forecast Next 4 Hours --- current_hour = datetime.now().hour hourly_temps = data["hourly"]["temperature_2m"] hourly_precip = data["hourly"]["precipitation"] hourly_codes = data["hourly"]["weather_code"] - + # Start from next hour start_idx = current_hour + 1 end_idx = min(start_idx + 4, len(hourly_temps)) - + next_temps = hourly_temps[start_idx:end_idx] next_precip = hourly_precip[start_idx:end_idx] next_codes = hourly_codes[start_idx:end_idx] if next_temps: report += " Прогноз на ближайшие 4 часа: " - - # Group by roughly similar weather to avoid repetition? - # Or just list them simply. - # "В 14:00 -5, ясно. В 15:00 -5, снег." -> a bit verbose. - # Simplified: "Температура около -5, возможен слабый снег." - - # Let's verify if weather changes significantly. - # If consistent, summarize. If not, list. - + # Simple approach for TTS: avg_temp = round(sum(next_temps) / len(next_temps)) - + # Check if any precipitation is expected will_precip = any(p > 0 for p in next_precip) unique_codes = set(next_codes) - + # Determine dominant weather description if len(unique_codes) == 1: weather_desc = get_wmo_description(list(unique_codes)[0]) @@ -130,15 +461,27 @@ def get_weather_report() -> str: else: weather_desc = "переменная облачность" - report += f"температура около {avg_temp} градусов, {weather_desc}." + report += f"температура около {get_temperature_text(avg_temp)}, {weather_desc}." if will_precip: report += " Ожидаются осадки." else: report += " Без существенных осадков." - + return report + except requests.exceptions.ConnectionError: + print(f"❌ Ошибка подключения к сервису погоды: невозможно подключиться к серверу") + return f"Не удалось подключиться к сервису погоды. Проверьте интернет-соединение." + except requests.exceptions.Timeout: + print(f"❌ Таймаут запроса к сервису погоды") + return f"Время ожидания ответа от сервиса погоды истекло." + except requests.exceptions.HTTPError as e: + print(f"❌ HTTP ошибка при получении погоды: {e}") + return f"Ошибка при получении данных о погоде: {e}" + except KeyError as e: + print(f"❌ Ошибка структуры данных погоды: {e}") + return f"Получены некорректные данные о погоде." except Exception as e: print(f"❌ Ошибка получения погоды: {e}") return "Не удалось получить полные данные о погоде." \ No newline at end of file diff --git a/app/main.py b/app/main.py index dcee4d6..93cd40d 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ import re import signal import sys import threading +import time from collections import deque # Для воспроизведения звуков (mp3) @@ -61,8 +62,14 @@ def signal_handler(sig, frame): Позволяет корректно завершить работу программы, освободив ресурсы (микрофон, модели). """ print("\n\n👋 Завершение работы...") - cleanup_wakeword() # Остановка Porcupine - cleanup_stt() # Остановка Deepgram + try: + cleanup_wakeword() # Остановка Porcupine + except Exception as e: + print(f"Ошибка при остановке wakeword: {e}") + try: + cleanup_stt() # Остановка Deepgram + except Exception as e: + print(f"Ошибка при остановке STT: {e}") sys.exit(0) @@ -180,8 +187,20 @@ def main(): # (True = режим диалога, слушаем сразу. False = ждем "Alexandr") skip_wakeword = False + # Переменная для отслеживания последней проверки здоровья STT + last_stt_check = time.time() + # БЕСКОНЕЧНЫЙ ЦИКЛ РАБОТЫ while True: + # Периодическая проверка здоровья STT каждые 10 минут + if time.time() - last_stt_check > 600: # 10 минут = 600 секунд + try: + recognizer = get_recognizer() + if hasattr(recognizer, 'check_connection_health'): + recognizer.check_connection_health() + last_stt_check = time.time() + except Exception as e: + print(f"Ошибка при проверке здоровья STT: {e}") try: # Гарантируем, что микрофон детектора wake word освобожден stop_wakeword_monitoring() @@ -201,8 +220,8 @@ def main(): # --- Шаг 1: Активация --- if not skip_wakeword: - # Ожидание фразы "Alexandr". Используем таймаут 1 сек, чтобы часто проверять будильники. - detected = wait_for_wakeword(timeout=1.0) + # Ожидание фразы "Alexandr". Используем таймаут 0.5 сек, чтобы чаще проверять будильники. + detected = wait_for_wakeword(timeout=0.5) # Если время вышло, а фразы не было — начинаем цикл заново (проверяем будильники) if not detected: @@ -212,13 +231,34 @@ def main(): if ding_sound: ding_sound.play() - # Фраза услышана! Слушаем команду пользователя (7 секунд тишины макс) - user_text = listen(timeout_seconds=7.0) + # Фраза услышана! Слушаем команду пользователя (5 секунд тишины макс) + try: + user_text = listen(timeout_seconds=5.0) + except Exception as e: + print(f"Ошибка при прослушивании: {e}") + print("Переинициализация STT...") + try: + cleanup_stt() + get_recognizer().initialize() + except Exception as init_error: + print(f"Ошибка переинициализации STT: {init_error}") + continue # Продолжаем цикл else: # Режим диалога (Follow-up): ждем продолжения речи без "Alexandr" - print("👂 Слушаю продолжение диалога (5 сек)...") - # Ждем начала речи 5 сек. Если начали говорить, слушаем до 10 сек. - user_text = listen(timeout_seconds=10.0, detection_timeout=5.0) + print("👂 Слушаю продолжение диалога (3 сек)...") + # Ждем начала речи 3 сек. Если начали говорить, слушаем до 7 сек. + try: + user_text = listen(timeout_seconds=7.0, detection_timeout=3.0) + except Exception as e: + print(f"Ошибка при прослушивании: {e}") + print("Переинициализация STT...") + try: + cleanup_stt() + get_recognizer().initialize() + except Exception as init_error: + print(f"Ошибка переинициализации STT: {init_error}") + skip_wakeword = False + continue if not user_text: # Пользователь промолчал — выходим из режима диалога, засыпаем. @@ -326,10 +366,42 @@ def main(): "нужен ли зонт", "брать ли зонт", "прогноз погоды", + "че там на улице", + "что там на улице", + "как на улице", + "как на улице-то", ] - if any(trigger in user_text.lower() for trigger in weather_triggers): - weather_report = get_weather_report() + # Проверяем, содержит ли запрос информацию о конкретном городе + requested_city = None + user_text_lower = user_text.lower() + + # Проверяем наличие упоминания города в запросе (например, "погода в Нью-Йорке", "какая погода в Москве") + import re + city_patterns = [ + r"в\s+городе\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "в городе Волгоград" + r"в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "в Нью-Йорке" - улучшенный паттерн для составных названий + r"погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "погода в Москве" - улучшенный паттерн + r"погода\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)\s+(?:какая|сейчас|там)", # "погода Москва какая" + r"(?:какая|как)\s+погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", # "какая погода в Москве" + ] + + for pattern in city_patterns: + match = re.search(pattern, user_text_lower, re.IGNORECASE) + if match: + potential_city = match.group(1).strip() + # Проверяем, что это не местоимение или другое слово, а реально название города + invalid_words = ["этом", "том", "той", "тут", "здесь", "там", "всё", "все", "всей", "всего", "всем", "всеми", "городе", "город", "село", "деревня", "посёлок", "аул", "станция", "область", "район", "край", "республика"] + if potential_city and len(potential_city) > 1 and not any(word in potential_city for word in invalid_words): + requested_city = potential_city.title() # Приводим к формату "Нью-Йорк", "Москва" + break + + # Проверяем, содержит ли запрос одну из погодных команд + has_weather_trigger = any(trigger in user_text_lower for trigger in weather_triggers) + + if has_weather_trigger: + from .features.weather import get_weather_report + weather_report = get_weather_report(requested_city) clean_report = clean_response(weather_report, language="ru") speak(clean_report) last_response = clean_report @@ -362,9 +434,21 @@ def main(): ) speak(prompt) # Слушаем саму фразу на нужном языке - text_to_translate = listen( - timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang - ) + try: + text_to_translate = listen( + timeout_seconds=7.0, detection_timeout=5.0, lang=source_lang + ) + except Exception as e: + print(f"Ошибка при прослушивании для перевода: {e}") + print("Переинициализация STT...") + try: + cleanup_stt() + get_recognizer().initialize() + except Exception as init_error: + print(f"Ошибка переинициализации STT: {init_error}") + speak("Произошла ошибка при распознавании речи.") + skip_wakeword = False + continue if not text_to_translate: speak("Я не расслышал текст для перевода.")