diff --git a/.env.example b/.env.example index e446601..32f8a07 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ AI_PROVIDER= # OPENROUTER_API_KEY=your_openrouter_api_key_here OPENROUTER_MODEL=openai/gpt-4o-mini OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions +AI_CHAT_TEMPERATURE=0.9 +AI_CHAT_MAX_TOKENS=160 +AI_CHAT_MAX_CHARS=240 +AI_INTENT_TEMPERATURE=0.0 +AI_TRANSLATION_TEMPERATURE=0.2 # OpenAI # OPENAI_API_KEY=your_openai_api_key_here @@ -35,6 +40,13 @@ OLLAMA_API_URL=http://localhost:11434/v1/chat/completions DEEPGRAM_API_KEY=your_deepgram_api_key_here PORCUPINE_ACCESS_KEY=your_porcupine_access_key_here PORCUPINE_SENSITIVITY=0.8 +# Anti-phantom wake word filter (RMS gate). +# Increase values if random activations persist; lower them if wake word becomes too hard to trigger. +# If the mic reopens and instantly re-triggers, keep RMS as-is and raise WAKEWORD_REOPEN_GRACE_SECONDS. +# WAKEWORD_MIN_RMS=120 +# WAKEWORD_RMS_MULTIPLIER=1.7 +# WAKEWORD_HIT_COOLDOWN_SECONDS=1.2 +# WAKEWORD_REOPEN_GRACE_SECONDS=0.45 # Optional audio device overrides (substring match by name or exact PortAudio index) # AUDIO_INPUT_DEVICE_NAME=pulse # AUDIO_INPUT_DEVICE_INDEX=2 diff --git a/.gitignore b/.gitignore index 52d117e..09401f0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ env.bak/ venv.bak/ .qwen qwen.md +.tmp/ # AI configs diff --git a/Makefile b/Makefile index b81a8fe..26d594e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ .PHONY: run check qwen-context +PYTHON := python3 +ifneq ($(wildcard .venv/bin/python),) +PYTHON := .venv/bin/python +endif + run: - python run.py + $(PYTHON) run.py check: ./scripts/qwen-check.sh diff --git a/README.md b/README.md index c038f23..02e9d3d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ flowchart TD F --> G[Follow-up режим или ожидание wake word] ``` -## Что Важно В Этой Реализации +## Что важно в этой реализации - Контекст диалога хранится в памяти текущей сессии, поэтому после первого вопроса можно продолжать разговор без потери нити. - Системная роль ассистента и `ROLE_JSON` сохраняются для всех поддерживаемых AI-провайдеров. @@ -68,7 +68,7 @@ sudo apt-get install -y portaudio19-dev libasound2-dev mpg123 mpv pulseaudio-uti ### 2) Установка Python-зависимостей ```bash -git clone +git clone https://gitea.futuree.ru/future/alexander_smart-speaker.git cd alexander_smart-speaker python3 -m venv venv source venv/bin/activate @@ -157,7 +157,7 @@ python run.py | `AUDIO_OUTPUT_DEVICE_NAME` | Нет | auto | Подстрока имени динамика/выхода (например `pulse`) | | `AUDIO_OUTPUT_DEVICE_INDEX` | Нет | auto | Индекс PortAudio для вывода (приоритетнее `AUDIO_OUTPUT_DEVICE_NAME`) | | `STT_START_SOUND_PATH` | Нет | `assets/sounds/alisa-golosovoj-pomoschnik.mp3` | Короткий звук после wake word и перед стартом STT (wav/mp3) | -| `STT_START_SOUND_VOLUME` | Нет | `0.25` | Громкость звука старта STT (0..1) | +| `STT_START_SOUND_VOLUME` | Нет | `1.0` | Громкость звука старта STT (в текущей версии фиксирована на 100%) | | `TTS_EN_SPEAKER` | Нет | `en_0` | Английский голос TTS | | `WEATHER_LAT` | Нет | - | Широта города по умолчанию | | `WEATHER_LON` | Нет | - | Долгота города по умолчанию | diff --git a/app/audio/sound_level.py b/app/audio/sound_level.py index 096d385..2945045 100644 --- a/app/audio/sound_level.py +++ b/app/audio/sound_level.py @@ -11,20 +11,57 @@ import re import platform from ..core.roman import replace_roman_numerals +try: + import pymorphy3 + + _MORPH = pymorphy3.MorphAnalyzer() +except Exception: + _MORPH = None + # Карта для перевода слов в цифры ("пять" -> 5) NUMBER_MAP = { + "ноль": 0, "один": 1, + "одна": 1, "раз": 1, + "единица": 1, + "единичка": 1, "два": 2, + "две": 2, + "двойка": 2, + "двоечка": 2, "три": 3, + "тройка": 3, + "троечка": 3, "четыре": 4, + "четверка": 4, + "четверочка": 4, "пять": 5, + "пятерка": 5, + "пятерочка": 5, "шесть": 6, + "шестерка": 6, + "шестерочка": 6, "семь": 7, + "семерка": 7, + "семерочка": 7, "восемь": 8, + "восьмерка": 8, + "восьмерочка": 8, "девять": 9, + "девятка": 9, + "девяточка": 9, "десять": 10, + "десятка": 10, + "десяточка": 10, } +_VOLUME_COMMAND_RE = re.compile(r"\b(громкост\w*|звук\w*|volume)\b") + + +def _lemmatize(token: str) -> str: + if _MORPH is None: + return token + return _MORPH.parse(token)[0].normal_form.replace("ё", "е") def _get_volume_command(level: int): @@ -149,16 +186,25 @@ def parse_volume_text(text: str) -> int | None: Пытается найти число громкости в тексте. Понимает и цифры ("5"), и слова ("пять"). """ - text = replace_roman_numerals(text.lower()) + text = replace_roman_numerals(text.lower().replace("ё", "е")) - # 1. Ищем цифры (1-10) - num_match = re.search(r"\b(10|[1-9])\b", text) - if num_match: - return int(num_match.group()) + # 1. Ищем цифры в любом месте фразы. + for match in re.finditer(r"\d+", text): + value = int(match.group()) + if 1 <= value <= 10: + return value - # 2. Ищем слова из словаря - for word, value in NUMBER_MAP.items(): - if word in text: + # 2. Ищем числительные и разговорные формы по леммам: + # "семерку", "десяточку", "на двух" -> 7, 10, 2. + for token in re.findall(r"[a-zA-Zа-яА-ЯёЁ]+", text): + value = NUMBER_MAP.get(_lemmatize(token)) + if value is not None and 1 <= value <= 10: return value return None + + +def is_volume_command(text: str) -> bool: + if not text: + return False + return bool(_VOLUME_COMMAND_RE.search(text.lower().replace("ё", "е"))) diff --git a/app/audio/stt.py b/app/audio/stt.py index b1843ba..d00e314 100644 --- a/app/audio/stt.py +++ b/app/audio/stt.py @@ -8,14 +8,13 @@ Supports Russian (default) and English. # Использует Deepgram API через веб-сокеты для потокового распознавания в реальном времени. import asyncio -import re import time import pyaudio import logging import contextlib import threading from datetime import datetime, timedelta -from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE, WAKE_WORD_ALIASES +from ..core.config import DEEPGRAM_API_KEY, SAMPLE_RATE from deepgram import ( DeepgramClient, DeepgramClientOptions, @@ -25,13 +24,14 @@ 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 +from ..core.commands import is_fast_command # --- Патч (исправление) для библиотеки websockets --- # Явно задаём таймауты подключения, чтобы не зависать на долгом handshake. _original_connect = websockets.sync.client.connect -DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 3.0 -DEEPGRAM_CONNECT_WAIT_SECONDS = 4.0 +DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 5.0 +DEEPGRAM_CONNECT_WAIT_SECONDS = 6.5 DEEPGRAM_CONNECT_POLL_SECONDS = 0.001 SENDER_STOP_WAIT_SECONDS = 2.5 SENDER_FORCE_RELEASE_WAIT_SECONDS = 2.5 @@ -62,28 +62,6 @@ POST_SPEECH_SILENCE_TIMEOUT_SECONDS = 2.0 # Фактическое завершение происходит примерно после 2.0 сек тишины после речи. MAX_ACTIVE_SPEECH_SECONDS = 300.0 -_FAST_STOP_UTTERANCE_RE = re.compile( - r"^(?:(?:" + "|".join(re.escape(alias) for alias in WAKE_WORD_ALIASES) + r")\s+)?" - r"(?:стоп|хватит|перестань|прекрати|замолчи|тихо|пауза)" - r"(?:\s+(?:пожалуйста|please))?$", - flags=re.IGNORECASE, -) - - -def _normalize_command_text(text: str) -> str: - normalized = text.lower().replace("ё", "е") - normalized = re.sub(r"[^\w\s]+", " ", normalized, flags=re.UNICODE) - normalized = re.sub(r"\s+", " ", normalized, flags=re.UNICODE).strip() - return normalized - - -def _is_fast_stop_utterance(text: str) -> bool: - normalized = _normalize_command_text(text) - if not normalized: - return False - return _FAST_STOP_UTTERANCE_RE.fullmatch(normalized) is not None - - class SpeechRecognizer: """Класс распознавания речи через Deepgram.""" @@ -280,7 +258,7 @@ class SpeechRecognizer: dg_connection: Активное соединение с Deepgram. timeout_seconds: Аварийный лимит длительности активной речи. detection_timeout: Время ожидания начала речи. - fast_stop: Если True, короткая стоп-фраза завершает STT после 1с тишины. + fast_stop: Если True, короткие системные команды завершают STT раньше. """ self.transcript = "" transcript_parts = [] @@ -296,6 +274,8 @@ class SpeechRecognizer: # События для синхронизации stop_event = asyncio.Event() # Пора останавливаться speech_started_event = asyncio.Event() # Речь обнаружена (VAD) + connection_ready_event = threading.Event() # WS с Deepgram готов + connection_failed_event = threading.Event() # WS с Deepgram завершился ошибкой last_speech_activity = time.monotonic() first_speech_activity_at = None session_error = {"message": None} @@ -338,14 +318,13 @@ class SpeechRecognizer: except RuntimeError: pass - if fast_stop: - if _is_fast_stop_utterance(sentence): - self.transcript = sentence - try: - loop.call_soon_threadsafe(request_stop) - except RuntimeError: - pass - return + if fast_stop and is_fast_command(sentence): + self.transcript = sentence + try: + loop.call_soon_threadsafe(request_stop) + except RuntimeError: + pass + return if result.is_final: # Собираем только финальные (подтвержденные) фразы @@ -470,6 +449,7 @@ class SpeechRecognizer: print( f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)" ) + connection_failed_event.set() loop.call_soon_threadsafe(request_stop) return @@ -479,15 +459,18 @@ class SpeechRecognizer: f"Failed to start Deepgram connection: {connect_result['error']}" ) print(f"Failed to start Deepgram connection: {connect_result['error']}") + connection_failed_event.set() loop.call_soon_threadsafe(request_stop) return if connect_result["ok"] is False: mark_session_error("Failed to start Deepgram connection") print("Failed to start Deepgram connection") + connection_failed_event.set() loop.call_soon_threadsafe(request_stop) return + connection_ready_event.set() print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...") # 3. Отправляем накопленный буфер @@ -522,6 +505,7 @@ class SpeechRecognizer: except Exception as e: mark_session_error(f"Audio send error: {e}") print(f"Audio send error: {e}") + connection_failed_event.set() with contextlib.suppress(RuntimeError): loop.call_soon_threadsafe(request_stop) finally: @@ -551,26 +535,56 @@ class SpeechRecognizer: and effective_detection_timeout > 0 and not stop_event.is_set() ): - speech_wait_task = asyncio.create_task(speech_started_event.wait()) - stop_wait_task = asyncio.create_task(stop_event.wait()) - try: - done, pending = await asyncio.wait( - {speech_wait_task, stop_wait_task}, - timeout=effective_detection_timeout, - return_when=asyncio.FIRST_COMPLETED, - ) - finally: - for task in (speech_wait_task, stop_wait_task): - if not task.done(): - task.cancel() - await asyncio.gather( - speech_wait_task, stop_wait_task, return_exceptions=True - ) + # Важно: не считаем пользователя "молчаливым", пока WS-соединение + # с Deepgram еще не поднялось. + connect_ready_deadline = time.monotonic() + max( + effective_detection_timeout + 0.25, + DEEPGRAM_CONNECT_WAIT_SECONDS + 0.75, + ) + while ( + not stop_event.is_set() + and not connection_ready_event.is_set() + and time.monotonic() < connect_ready_deadline + ): + if connection_failed_event.is_set(): + break + await asyncio.sleep(0.05) - if not done: - # Если за detection_timeout никто не начал говорить, выходим + if ( + not stop_event.is_set() + and not connection_ready_event.is_set() + and not connection_failed_event.is_set() + ): + mark_session_error("Deepgram connection was not ready before speech timeout.") request_stop() + if ( + stop_event.is_set() + or connection_failed_event.is_set() + or not connection_ready_event.is_set() + ): + request_stop() + else: + speech_wait_task = asyncio.create_task(speech_started_event.wait()) + stop_wait_task = asyncio.create_task(stop_event.wait()) + try: + done, pending = await asyncio.wait( + {speech_wait_task, stop_wait_task}, + timeout=effective_detection_timeout, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + for task in (speech_wait_task, stop_wait_task): + if not task.done(): + task.cancel() + await asyncio.gather( + speech_wait_task, stop_wait_task, return_exceptions=True + ) + + if not done: + # Если за detection_timeout после поднятия WS никто не начал говорить, выходим. + request_stop() + # 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS. # Добавляем длинный защитный лимит, чтобы сессия не зависла навсегда. if not stop_event.is_set(): @@ -687,7 +701,7 @@ class SpeechRecognizer: timeout_seconds: Защитный лимит длительности активной речи. detection_timeout: Сколько ждать начала речи перед тем как сдаться. lang: Язык ("ru" или "en"). - fast_stop: Быстрое завершение для коротких stop-команд. + fast_stop: Быстрое завершение для коротких системных команд. """ if not self.dg_client: self.initialize() diff --git a/app/audio/tts.py b/app/audio/tts.py index 5c164b1..9960179 100644 --- a/app/audio/tts.py +++ b/app/audio/tts.py @@ -19,12 +19,14 @@ import sounddevice as sd import torch from ..core.audio_manager import get_audio_manager -from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER +from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER, TTS_SPEED # Подавляем предупреждения Silero о длинном тексте (мы сами его режем) warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols") _EN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9'-]*") +_MIXED_TTS_BUFFERED_SWITCHES = 3 +_INTERRUPT_POLL_SECONDS = 0.01 class TextToSpeech: @@ -34,6 +36,7 @@ class TextToSpeech: self.model_ru = None self.model_en = None self.sample_rate = TTS_SAMPLE_RATE + self.speed_factor = float(TTS_SPEED) self.speaker_ru = TTS_SPEAKER self.speaker_en = TTS_EN_SPEAKER self._interrupted = False @@ -41,6 +44,23 @@ class TextToSpeech: self._audio_manager = None self._output_device_index = None + def _apply_speed(self, audio_np: np.ndarray) -> np.ndarray: + """Применяет небольшой time-stretch без изменения остальной логики TTS.""" + audio = np.asarray(audio_np, dtype=np.float32) + if audio.size == 0: + return audio + + speed = max(0.85, min(1.15, float(self.speed_factor))) + if abs(speed - 1.0) < 0.01: + return audio + + # speed < 1.0 -> медленнее (длина массива больше), speed > 1.0 -> быстрее. + target_length = max(1, int(round(audio.size / speed))) + x_old = np.arange(audio.size, dtype=np.float32) + x_new = np.linspace(0.0, float(max(0, audio.size - 1)), target_length) + stretched = np.interp(x_new, x_old, audio) + return np.asarray(stretched, dtype=np.float32) + def _load_model(self, language: str): """ Загрузка и кэширование модели Silero TTS. @@ -52,21 +72,12 @@ class TextToSpeech: if self.model_en: return self.model_en print("📦 Загрузка модели Silero TTS (en)...") - try: - model, _ = torch.hub.load( - repo_or_dir="snakers4/silero-models", - model="silero_tts", - language="en", - speaker="v5_en", - ) - except Exception as exc: - print(f"⚠️ Не удалось загрузить v5_en, пробую v3_en: {exc}") - model, _ = torch.hub.load( - repo_or_dir="snakers4/silero-models", - model="silero_tts", - language="en", - speaker="v3_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 @@ -185,28 +196,7 @@ class TextToSpeech: 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 model.speakers: - if language == "ru": - male_speakers = ("eugene", "aidar") - if speaker not in model.speakers or speaker not in male_speakers: - for candidate in male_speakers: - if candidate in model.speakers: - speaker = candidate - break - else: - speaker = model.speakers[0] - elif speaker not in model.speakers: - speaker = model.speakers[0] + model, speaker = self._get_model_and_speaker(language) # Разбиваем текст на куски chunks = self._split_text(text) @@ -233,7 +223,7 @@ class TextToSpeech: ) # Конвертация в numpy массив для sounddevice - audio_np = audio.numpy() + audio_np = self._apply_speed(audio.numpy()) if check_interrupt: if not self._play_audio_with_interrupt(audio_np, check_interrupt): @@ -256,10 +246,104 @@ class TextToSpeech: else: return False + def _get_model_and_speaker(self, language: str): + """Возвращает модель и подходящий голос для языка.""" + # Выбор модели + 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 model.speakers: + if language == "ru": + male_speakers = ("eugene", "aidar") + if speaker not in model.speakers or speaker not in male_speakers: + for candidate in male_speakers: + if candidate in model.speakers: + speaker = candidate + break + else: + speaker = model.speakers[0] + elif speaker not in model.speakers: + speaker = model.speakers[0] + + return model, speaker + + def _synthesize_language_audio(self, text: str, language: str) -> np.ndarray | None: + """Собирает аудио для одного языка без промежуточного воспроизведения.""" + if not text.strip(): + return np.asarray([], dtype=np.float32) + + model, speaker = self._get_model_and_speaker(language) + chunks = self._split_text(text) + audio_parts = [] + + for chunk in chunks: + if self._interrupted: + return None + audio = model.apply_tts(text=chunk, speaker=speaker, sample_rate=self.sample_rate) + audio_parts.append(self._apply_speed(audio.numpy())) + + if not audio_parts: + return np.asarray([], dtype=np.float32) + + return np.concatenate(audio_parts) + + def _count_language_switches(self, segments: list[tuple[str, str]]) -> int: + if len(segments) < 2: + return 0 + return sum( + 1 + for idx in range(1, len(segments)) + if segments[idx - 1][1] != segments[idx][1] + ) + + def _speak_mixed_buffered( + self, segments: list[tuple[str, str]], check_interrupt=None + ) -> bool: + """Сначала собирает mixed RU/EN аудио, затем проигрывает единым потоком.""" + print(f"🔊 Mixed TTS: буферизация сегментов ({len(segments)} шт.)") + self._interrupted = False + self._stop_flag.clear() + + audio_parts = [] + for idx, (segment, lang) in enumerate(segments, start=1): + if not segment.strip(): + continue + if check_interrupt and check_interrupt(): + self._interrupted = True + return False + try: + audio_np = self._synthesize_language_audio(segment, language=lang) + except Exception as exc: + print(f"❌ Ошибка mixed TTS (сегмент {idx}/{len(segments)}): {exc}") + return False + if audio_np is None: + return False + if audio_np.size: + audio_parts.append(audio_np) + + if not audio_parts: + return True + + full_audio = np.concatenate(audio_parts) + if check_interrupt: + return self._play_audio_with_interrupt(full_audio, check_interrupt) + return self._play_audio_blocking(full_audio) + def _speak_mixed( self, segments: list[tuple[str, str]], check_interrupt=None ) -> bool: """Озвучивание текста с переключением RU/EN по сегментам.""" + if self._count_language_switches(segments) >= _MIXED_TTS_BUFFERED_SWITCHES: + return self._speak_mixed_buffered( + segments, check_interrupt=check_interrupt + ) + for segment, lang in segments: if not segment.strip(): continue @@ -390,6 +474,7 @@ class TextToSpeech: return except Exception: pass + time.sleep(_INTERRUPT_POLL_SECONDS) def _play_with_interrupt_sounddevice( self, audio_np: np.ndarray, check_interrupt @@ -407,11 +492,18 @@ class TextToSpeech: # Запускаем воспроизведение (неблокирующее) sd.play(audio_np, self.sample_rate) - # Ждем окончания воспроизведения в цикле - while sd.get_stream().active: + # Ждем окончания воспроизведения в цикле. + while True: if self._interrupted: break - time.sleep(0.02) # Уменьшаем задержку для более быстрого реагирования + stream = sd.get_stream() + if stream is None or not stream.active: + break + time.sleep(0.02) + + if not self._interrupted: + # Добираем хвост буфера даже если stream.active мигнул в False чуть раньше. + sd.wait() finally: # Сообщаем потоку-наблюдателю, что пора завершаться diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py index 4ed938a..63c285e 100644 --- a/app/audio/wakeword.py +++ b/app/audio/wakeword.py @@ -9,12 +9,26 @@ Listens for the configured wake word. import pvporcupine import pyaudio import struct +import io +import wave +import time import numpy as np +import httpx +from collections import deque +from deepgram import DeepgramClient +from deepgram.clients.listen.v1.rest.options import PrerecordedOptions from ..core.config import ( + DEEPGRAM_API_KEY, PORCUPINE_ACCESS_KEY, PORCUPINE_KEYWORD_PATH, PORCUPINE_SENSITIVITY, + WAKEWORD_HIT_COOLDOWN_SECONDS, + WAKEWORD_ENABLE_FALLBACK_STT, + WAKEWORD_MIN_RMS, + WAKEWORD_REOPEN_GRACE_SECONDS, + WAKEWORD_RMS_MULTIPLIER, WAKE_WORD, + WAKE_WORD_ALIASES, ) from ..core.audio_manager import get_audio_manager @@ -33,6 +47,19 @@ class WakeWordDetector: self._resampled_pcm_buffer = np.array([], dtype=np.int16) self._stream_closed = True # Флаг состояния потока (закрыт/открыт) self._last_hit_ts = 0.0 + self._fallback_dg_client = None + self._fallback_pre_roll = deque(maxlen=4) + self._fallback_frames = [] + self._fallback_active = False + self._fallback_silence_frames = 0 + self._fallback_last_attempt_ts = 0.0 + self._fallback_last_error_ts = 0.0 + self._stream_opened_ts = 0.0 + self._rms_history = deque(maxlen=220) + self._wakeword_aliases_compact = { + self._compact_text(WAKE_WORD), + *(self._compact_text(alias) for alias in WAKE_WORD_ALIASES), + } def initialize(self): """Инициализация Porcupine и PyAudio.""" @@ -87,6 +114,211 @@ class WakeWordDetector: ) self._resampled_pcm_buffer = np.array([], dtype=np.int16) self._stream_closed = False + self._stream_opened_ts = time.time() + self._reset_fallback_state() + + @staticmethod + def _compute_rms(pcm: np.ndarray) -> float: + if pcm.size == 0: + return 0.0 + as_float = pcm.astype(np.float32) + return float(np.sqrt(np.mean(as_float * as_float))) + + @staticmethod + def _compact_text(text: str) -> str: + text = str(text or "").lower().replace("ё", "е") + return "".join(ch for ch in text if ch.isalnum()) + + def _remember_rms(self, rms: float): + if rms <= 0: + return + self._rms_history.append(float(rms)) + + def _noise_floor_rms(self) -> float: + if not self._rms_history: + return 0.0 + # Низкий процентиль устойчив к редким всплескам/голосу. + return float(np.percentile(np.asarray(self._rms_history, dtype=np.float32), 20)) + + def _wakeword_rms_threshold(self) -> float: + floor = self._noise_floor_rms() + dynamic = floor * float(WAKEWORD_RMS_MULTIPLIER) + # Защитный максимум, чтобы в очень шумном окружении не "убить" детект полностью. + dynamic = min(dynamic, float(WAKEWORD_MIN_RMS) * 4.0) + return max(float(WAKEWORD_MIN_RMS), dynamic) + + def _is_hit_in_guard_window( + self, now_ts: float, *, ignore_hit_cooldown: bool = False + ) -> bool: + if ( + not ignore_hit_cooldown + and now_ts - self._last_hit_ts < float(WAKEWORD_HIT_COOLDOWN_SECONDS) + ): + return True + if ( + self._stream_opened_ts > 0 + and now_ts - self._stream_opened_ts < float(WAKEWORD_REOPEN_GRACE_SECONDS) + ): + return True + return False + + def _accept_porcupine_hit( + self, + pcm: np.ndarray, + now_ts: float, + *, + ignore_hit_cooldown: bool = False, + during_tts: bool = False, + ) -> bool: + if self._is_hit_in_guard_window( + now_ts, ignore_hit_cooldown=ignore_hit_cooldown + ): + return False + rms = self._compute_rms(pcm) + # Для "чистого" Porcupine оставляем мягкий амплитудный фильтр: + # он отсеивает тишину/щелчки и ложные фаны от фонового шума. + # Во время TTS делаем фильтр строже, чтобы собственная колонка + # не "будила" ассистента. + factor = 0.95 if during_tts else 0.75 + threshold = max(80.0, self._wakeword_rms_threshold() * factor) + if rms < threshold: + return False + self._last_hit_ts = now_ts + return True + + def _reset_fallback_state(self): + self._fallback_pre_roll.clear() + self._fallback_frames = [] + self._fallback_active = False + self._fallback_silence_frames = 0 + + def _get_fallback_client(self): + if not WAKEWORD_ENABLE_FALLBACK_STT: + return None + if not DEEPGRAM_API_KEY: + return None + if self._fallback_dg_client is None: + self._fallback_dg_client = DeepgramClient(DEEPGRAM_API_KEY) + return self._fallback_dg_client + + def _pcm_to_wav_bytes(self, pcm: np.ndarray) -> bytes: + buffer = io.BytesIO() + with wave.open(buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(int(self.porcupine.sample_rate)) + wav_file.writeframes(np.asarray(pcm, dtype=np.int16).tobytes()) + return buffer.getvalue() + + def _transcribe_wakeword_candidate(self, pcm: np.ndarray) -> bool: + client = self._get_fallback_client() + if client is None or pcm.size == 0: + return False + + try: + response = client.listen.rest.v("1").transcribe_file( + {"buffer": self._pcm_to_wav_bytes(pcm)}, + PrerecordedOptions( + model="nova-2", + language="ru", + smart_format=False, + punctuate=False, + utterances=False, + numerals=False, + ), + timeout=httpx.Timeout(2.2, connect=2.2, read=2.2, write=2.2), + ) + except Exception as exc: + now = time.time() + if now - self._fallback_last_error_ts >= 30.0: + print(f"⚠️ Wake word fallback STT failed: {exc}") + self._fallback_last_error_ts = now + return False + + transcript = "" + confidence = None + try: + channels = response.results.channels or [] + if channels and channels[0].alternatives: + first_alt = channels[0].alternatives[0] + transcript = str(first_alt.transcript or "").strip() + try: + confidence = float(first_alt.confidence) + except Exception: + confidence = None + except Exception: + transcript = "" + confidence = None + + compact = self._compact_text(transcript) + if confidence is not None and confidence < 0.62: + return False + if compact in self._wakeword_aliases_compact: + print(f"✅ Wake word обнаружен fallback STT: {transcript}") + return True + return False + + def _check_fallback_wakeword( + self, + pcm: np.ndarray, + *, + during_tts: bool = False, + ignore_hit_cooldown: bool = False, + ) -> bool: + if not WAKEWORD_ENABLE_FALLBACK_STT: + return False + if self.porcupine is None: + return False + + rms = self._compute_rms(pcm) + base_threshold = self._wakeword_rms_threshold() + speech_factor = 1.1 if during_tts else 0.85 + speech_threshold = max(170.0, base_threshold * speech_factor) + silence_threshold = max(95.0, speech_threshold * 0.55) + silence_frames_to_finalize = 10 if during_tts else 8 + min_frames = 10 if during_tts else 7 + max_frames = 40 + min_attempt_interval = 2.5 if during_tts else 1.0 + + if rms >= speech_threshold: + if not self._fallback_active: + self._fallback_active = True + self._fallback_frames = list(self._fallback_pre_roll) + self._fallback_silence_frames = 0 + self._fallback_frames.append(np.asarray(pcm, dtype=np.int16)) + elif self._fallback_active: + self._fallback_frames.append(np.asarray(pcm, dtype=np.int16)) + if rms <= silence_threshold: + self._fallback_silence_frames += 1 + else: + self._fallback_silence_frames = 0 + + if len(self._fallback_frames) > max_frames: + self._reset_fallback_state() + elif self._fallback_silence_frames >= silence_frames_to_finalize: + candidate = np.concatenate(self._fallback_frames) if self._fallback_frames else np.asarray([], dtype=np.int16) + self._reset_fallback_state() + if len(candidate) >= min_frames * int(self.porcupine.frame_length): + now = time.time() + candidate_rms = self._compute_rms(candidate) + candidate_threshold = self._wakeword_rms_threshold() * ( + 0.95 if during_tts else 0.75 + ) + candidate_threshold = max(float(WAKEWORD_MIN_RMS), candidate_threshold) + if ( + now - self._fallback_last_attempt_ts >= min_attempt_interval + and not self._is_hit_in_guard_window( + now, ignore_hit_cooldown=ignore_hit_cooldown + ) + and candidate_rms >= candidate_threshold + ): + self._fallback_last_attempt_ts = now + if self._transcribe_wakeword_candidate(candidate): + self._last_hit_ts = now + return True + + self._fallback_pre_roll.append(np.asarray(pcm, dtype=np.int16)) + return False def stop_monitoring(self): """Явная остановка и закрытие потока (чтобы освободить микрофон для других задач).""" @@ -97,6 +329,8 @@ class WakeWordDetector: except Exception: pass self._stream_closed = True + self._stream_opened_ts = 0.0 + self._reset_fallback_state() def _resample_to_target_rate(self, pcm: np.ndarray) -> np.ndarray: target_rate = int(self.porcupine.sample_rate) @@ -160,14 +394,20 @@ class WakeWordDetector: # Читаем небольшой кусочек аудио (frame) pcm = self._read_porcupine_frame() + self._remember_rms(self._compute_rms(pcm)) # Обрабатываем фрейм через Porcupine keyword_index = self.porcupine.process(pcm.tolist()) # Если keyword_index >= 0, значит ключевое слово обнаружено if keyword_index >= 0: - print("✅ Wake word обнаружен!") - # Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram) + now = time.time() + if self._accept_porcupine_hit(pcm, now, during_tts=False): + print("✅ Wake word обнаружен!") + # Важно: закрываем поток, чтобы освободить микрофон для STT (Deepgram) + self.stop_monitoring() + return True + if self._check_fallback_wakeword(pcm): self.stop_monitoring() return True @@ -189,15 +429,25 @@ class WakeWordDetector: self._open_stream() pcm = self._read_porcupine_frame() + self._remember_rms(self._compute_rms(pcm)) keyword_index = self.porcupine.process(pcm.tolist()) if keyword_index >= 0: now = time.time() - if now - self._last_hit_ts < 0.2: # Уменьшаем интервал для более быстрой реакции + if not self._accept_porcupine_hit( + pcm, + now, + ignore_hit_cooldown=True, + during_tts=True, + ): return False - self._last_hit_ts = now print("🛑 Wake word обнаружен во время ответа!") return True + if self._check_fallback_wakeword( + pcm, during_tts=True, ignore_hit_cooldown=True + ): + print("🛑 Wake word обнаружен fallback STT во время ответа!") + return True return False except Exception: return False diff --git a/app/core/ai.py b/app/core/ai.py index acbf2c9..0a3dfdb 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -7,7 +7,12 @@ from typing import Optional import requests from .config import ( + AI_CHAT_MAX_CHARS, AI_PROVIDER, + AI_CHAT_MAX_TOKENS, + AI_CHAT_TEMPERATURE, + AI_INTENT_TEMPERATURE, + AI_TRANSLATION_TEMPERATURE, ANTHROPIC_API_KEY, ANTHROPIC_API_URL, ANTHROPIC_API_VERSION, @@ -31,15 +36,25 @@ from .config import ( ) _HTTP = requests.Session() +_CITATION_SQUARE_RE = re.compile(r"(?:\s*\[\d+\])+") +_CITATION_FULLWIDTH_RE = re.compile(r"【\d+[^】]*】") +_PUNCT_SPACING_RE = re.compile(r"\s+([,.;:!?…])") +_SENTENCE_BOUNDARY_RE = re.compile(r"([.!?…])\s+") +_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+") # Системный промпт _wake_word_aliases_text = ", ".join(WAKE_WORD_ALIASES) SYSTEM_PROMPT = f"""Ты — умный голосовой ассистент с человеческим поведением. Веди себя как живой человек: будь дружелюбным, естественным и немного эмоциональным, где это уместно. Твоя главная цель — помогать пользователю и поддерживать интересный диалог. -Отвечай кратко и по существу, на русском языке. +Отвечай на русском языке кратко и по существу: обычно 1-2 коротких предложения. +Если пользователь явно просит подробнее, можно до 4 коротких предложений без повторов и лишних вводных. Избегай длинных списков, сложного форматирования и спецсимволов, так как твои ответы озвучиваются голосом. +Не добавляй ссылки, сноски и маркеры источников (например, [1], [2], URL). Пиши в разговорном стиле, как при живом общении, но не забывай о вежливости и правильности твоих ответов. +Понимай юмор, иронию, сарказм, образные выражения, намеки и переносный смысл фраз. +Если пользователь шутит или говорит образно, сначала правильно восстанови его реальное намерение, затем ответь естественно и по смыслу. +Если в шутке или метафоре скрыта команда или просьба, трактуй ее по смыслу, а не буквально. ВАЖНО: Не используй в ответах панибратские или сленговые приветствия и обращения, такие как "Эй", "Хэй", "Слушай" в начале фразы и подобные. Тебя активируют словом "{WAKE_WORD}". Никогда не произноси это слово и его варианты ({_wake_word_aliases_text}) ни в каком ответе. Если пользователь спрашивает, как тебя зовут или как к тебе обращаться, отвечай нейтрально: "Я ваш голосовой ассистент".""" @@ -73,7 +88,18 @@ INTENT_SYSTEM_PROMPT = """Ты NLU-модуль голосовой колонк - Для "что играет" = music_action=current. - Для "включи жанр X" = music_action=play_genre, music_query=X. - Для "включи папку X" = music_action=play_folder, music_query=X. +- Если это будильник, ставь intent=alarm и нормализуй команду в одну из форм: + 1) Создание/изменение: "поставь будильник на HH:MM [по будням|по выходным|каждый день|по <дням>]" + 2) Показ списка: "покажи активные будильники" + 3) Удаление конкретного: "удали будильник на HH:MM [по будням|по выходным|по <дням>]" + 4) Удаление всех: "отмени все будильники" +- Если пользователь просит поставить/удалить будильник, но время не названо, normalized_command должен быть: + "поставь будильник" или "удали будильник". - normalized_command должен быть пригоден для командного парсера (без лишних слов). +- Понимай разговорные, шутливые, переносные, косвенные и ироничные формулировки. +- Восстанавливай намерение по смыслу, а не только по буквальным словам. +- Если в фразе есть скрытая прикладная команда для колонки, верни соответствующий intent и normalized_command. +- Если пользователь просто шутит или разговаривает без прикладной команды, выбирай smalltalk или chat, а не случайную системную команду. - Если уверенность низкая, ставь intent=none, music_action=none, confidence <= 0.4.""" _PROVIDER_ALIASES = { @@ -442,6 +468,60 @@ def _extract_json_object(raw_text: str) -> Optional[dict]: return None +def _sanitize_chat_response(text: str) -> str: + cleaned = str(text or "") + if not cleaned: + return "" + + cleaned = _CITATION_SQUARE_RE.sub("", cleaned) + cleaned = _CITATION_FULLWIDTH_RE.sub("", cleaned) + cleaned = _PUNCT_SPACING_RE.sub(r"\1", cleaned) + cleaned = re.sub(r"[ \t]+", " ", cleaned) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + + +def _truncate_chat_response(text: str, max_chars: int) -> str: + cleaned = str(text or "").strip() + if not cleaned: + return "" + + safe_limit = max(120, int(max_chars)) + if len(cleaned) <= safe_limit: + return cleaned + + sentences = [part.strip() for part in _SENTENCE_SPLIT_RE.split(cleaned) if part.strip()] + if sentences: + selected = [] + current_length = 0 + for sentence in sentences: + projected = current_length + len(sentence) + (1 if selected else 0) + if projected > safe_limit: + break + selected.append(sentence) + current_length = projected + + if selected: + result = " ".join(selected).rstrip(" ,;:-") + if result and result[-1] not in ".!?…": + result += "." + return result + + # Если первое предложение слишком длинное, режем аккуратно по слову. + first = sentences[0] + else: + first = cleaned + + clipped = first[:safe_limit].rstrip() + word_boundary = clipped.rfind(" ") + if word_boundary >= int(safe_limit * 0.6): + clipped = clipped[:word_boundary].rstrip() + clipped = clipped.rstrip(" ,;:-") + if clipped.endswith((".", "!", "?", "…")): + return clipped + return f"{clipped}..." + + def _send_request(messages, max_tokens, temperature, error_text): """ Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру. @@ -512,7 +592,7 @@ def interpret_assistant_intent(text: str) -> dict: response = _send_request( messages, max_tokens=220, - temperature=0.0, + temperature=AI_INTENT_TEMPERATURE, error_text="", ) payload = _extract_json_object(response) @@ -596,10 +676,12 @@ def ask_ai(messages_history: list) -> str: response = _send_request( messages, - max_tokens=500, - temperature=1.0, # Высокая температура для более живого общения + max_tokens=AI_CHAT_MAX_TOKENS, + temperature=AI_CHAT_TEMPERATURE, error_text="Произошла ошибка при обращении к AI. Попробуйте ещё раз.", ) + response = _sanitize_chat_response(response) + response = _truncate_chat_response(response, AI_CHAT_MAX_CHARS) if response: print(f"💬 Ответ AI: {response[:100]}...") @@ -610,6 +692,7 @@ def ask_ai_stream(messages_history: list): """ Generator that yields chunks of the AI response as they arrive. """ + response = None cfg, selection_error = _get_provider_settings() if selection_error: yield selection_error @@ -637,14 +720,46 @@ def ask_ai_stream(messages_history: list): response = _HTTP.post( cfg["api_url"], headers=_build_headers(cfg), - json=_build_payload(cfg, messages, 500, 1.0, stream=True), + json=_build_payload( + cfg, + messages, + AI_CHAT_MAX_TOKENS, + AI_CHAT_TEMPERATURE, + stream=True, + ), timeout=15, stream=True, ) response.raise_for_status() + # Для устойчивости TTS сначала собираем поток, затем чистим и аккуратно + # ограничиваем длину по границе предложения. + raw_parts = [] for chunk in _iter_stream_chunks(cfg, response): - yield chunk + if chunk: + raw_parts.append(chunk) + + full_text = _sanitize_chat_response("".join(raw_parts)) + full_text = _truncate_chat_response(full_text, AI_CHAT_MAX_CHARS) + if not full_text: + return + + # Отдаем кусками по предложениям, чтобы main.py мог начинать озвучку раньше. + parts = _SENTENCE_BOUNDARY_RE.split(full_text) + if not parts: + yield full_text + return + + sentence = "" + for part in parts: + if not part: + continue + sentence += part + if part in ".!?…": + yield sentence.strip() + " " + sentence = "" + if sentence.strip(): + yield sentence.strip() except requests.exceptions.Timeout: yield f"Извините, сервер {cfg['name']} не отвечает. Попробуйте позже." except requests.exceptions.RequestException as error: @@ -653,6 +768,12 @@ def ask_ai_stream(messages_history: list): except Exception as error: print(f"❌ Streaming Error ({cfg['name']}): {error}") yield "Произошла ошибка связи." + finally: + if response is not None: + try: + response.close() + except Exception: + pass def translate_text(text: str, source_lang: str, target_lang: str) -> str: @@ -683,17 +804,18 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str: response = _send_request( messages, max_tokens=160, - temperature=0.2, # Низкая температура для точности перевода + temperature=AI_TRANSLATION_TEMPERATURE, error_text="Произошла ошибка при переводе. Попробуйте ещё раз.", ) - cleaned = response.strip() + cleaned = _sanitize_chat_response(response).strip() + cleaned = re.sub(r"[*_`]+", "", cleaned) if not cleaned: return cleaned # Normalize to 2-3 variants separated by " / " parts = [] for chunk in re.split(r"(?:\s*/\s*|\n|;|\|)", cleaned): - item = chunk.strip(" \t-•") + item = chunk.strip(" \t-•\"'“”«»") if item: parts.append(item) if not parts: diff --git a/app/core/audio_manager.py b/app/core/audio_manager.py index 93c228d..571fa67 100644 --- a/app/core/audio_manager.py +++ b/app/core/audio_manager.py @@ -130,11 +130,9 @@ class AudioManager: if match_idx is not None: return match_idx - raise RuntimeError( - "Audio input initialization failed: could not find an input device " - f"matching AUDIO_INPUT_DEVICE_NAME={AUDIO_INPUT_DEVICE_NAME!r}. " - "Available input devices:\n" - + self.describe_input_devices() + print( + "⚠️ AUDIO_INPUT_DEVICE_NAME was set but no matching input device was found: " + f"{AUDIO_INPUT_DEVICE_NAME!r}. Falling back to default input selection." ) # Default input device (if PortAudio has one). @@ -176,11 +174,9 @@ class AudioManager: ) if match_idx is not None: return match_idx - raise RuntimeError( - "Audio output initialization failed: could not find an output device " - f"matching AUDIO_OUTPUT_DEVICE_NAME={AUDIO_OUTPUT_DEVICE_NAME!r}. " - "Available output devices:\n" - + self.describe_output_devices() + print( + "⚠️ AUDIO_OUTPUT_DEVICE_NAME was set but no matching output device was found: " + f"{AUDIO_OUTPUT_DEVICE_NAME!r}. Falling back to default output selection." ) default_idx = self._get_default_output_index() diff --git a/app/core/cleaner.py b/app/core/cleaner.py index 4a7034c..b6ab288 100644 --- a/app/core/cleaner.py +++ b/app/core/cleaner.py @@ -342,6 +342,7 @@ def numbers_to_words(text: str) -> str: case = "nominative" gender = "m" prep_clean = prep.strip().lower() if prep else None + parsed = None if prep_clean: morph_case = get_case_from_preposition(prep_clean) @@ -359,6 +360,7 @@ def numbers_to_words(text: str) -> str: # Спец-случай: "на 1 час" if ( prep_clean == "на" + and parsed is not None and parsed.normal_form in TIME_UNIT_LEMMAS and parsed.tag.gender in ("masc", "neut") ): diff --git a/app/core/commands.py b/app/core/commands.py index 7359223..0af14e2 100644 --- a/app/core/commands.py +++ b/app/core/commands.py @@ -4,6 +4,9 @@ Command parsing helpers. import re +from .config import WAKE_WORD, WAKE_WORD_ALIASES +from ..audio.sound_level import is_volume_command, parse_volume_text + _STOP_WORDS_STRICT = { "стоп", "хватит", @@ -31,6 +34,28 @@ _STOP_PATTERNS_LENIENT = [ r"\bдостаточно\b", ] _STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT] +_FAST_WEATHER_PHRASES = { + "какая погода", + "какая погода на улице", + "какая сейчас погода", + "какая сейчас погода на улице", + "что по погоде", + "погода", + "погода на улице", + "что на улице", + "что там на улице", + "че там на улице", +} +_FAST_MUSIC_PHRASES = { + "включи музыку", + "поставь музыку", + "играй музыку", + "play music", +} +_WAKEWORD_PREFIX_RE = re.compile( + rf"^(?:{'|'.join(re.escape(alias) for alias in sorted({WAKE_WORD.lower(), *WAKE_WORD_ALIASES}, key=len, reverse=True))})(?:\s+|$)", + re.IGNORECASE, +) def _normalize_text(text: str) -> str: @@ -40,6 +65,13 @@ def _normalize_text(text: str) -> str: return text +def normalize_command_text(text: str) -> str: + normalized = _normalize_text(text) + if not normalized: + return "" + return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip() + + def is_stop_command(text: str, mode: str = "strict") -> bool: """ Detect stop commands in text. @@ -64,3 +96,27 @@ def is_stop_command(text: str, mode: str = "strict") -> bool: return True return False + + +def is_fast_command(text: str) -> bool: + """ + Detect short commands that can stop STT early without waiting + for full utterance finalization. + """ + normalized = normalize_command_text(text) + if not normalized: + return False + + if is_stop_command(normalized, mode="strict"): + return True + + if normalized in _FAST_WEATHER_PHRASES: + return True + + if normalized in _FAST_MUSIC_PHRASES: + return True + + if is_volume_command(normalized) and parse_volume_text(normalized) is not None: + return True + + return False diff --git a/app/core/config.py b/app/core/config.py index 939626a..182d82e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -7,15 +7,49 @@ Loads environment variables from .env file. # Он загружает настройки из файла .env (переменные окружения) и определяет константы. import os +import re import time +from io import StringIO from pathlib import Path -from dotenv import load_dotenv +from dotenv import dotenv_values # Базовая директория проекта (корневая папка, где лежит .env) BASE_DIR = Path(__file__).resolve().parents[2] -# Загружаем переменные из файла .env в корневом каталоге -load_dotenv(BASE_DIR / ".env") +def _load_project_env(env_path: Path) -> None: + """ + Загружает .env, игнорируя строковый "шум" без формата KEY=VALUE. + Это делает конфиг устойчивым к человеческим комментариям без символа '#'. + """ + if not env_path.exists(): + return + + raw_text = env_path.read_text(encoding="utf-8") + sanitized_lines = [] + + for line in raw_text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + sanitized_lines.append(line) + continue + + if "=" in line: + key = line.split("=", 1)[0].strip() + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + sanitized_lines.append(line) + continue + + # Игнорируем невалидные строки, чтобы dotenv не шумел warning'ами. + sanitized_lines.append(f"# ignored invalid env line: {line}") + + parsed = dotenv_values(stream=StringIO("\n".join(sanitized_lines))) + for key, value in parsed.items(): + if key and value is not None and os.getenv(key) is None: + os.environ[key] = value + + +# Загружаем переменные из .env в корневом каталоге +_load_project_env(BASE_DIR / ".env") # --- Настройки AI --- # AI_PROVIDER опционален. Приоритет у единственного активного AI API key. @@ -29,6 +63,22 @@ OPENROUTER_API_URL = os.getenv( "OPENROUTER_API_URL", "https://openrouter.ai/api/v1/chat/completions" ) + +def _read_clamped_float_env(name: str, default: str, minimum: float, maximum: float) -> float: + try: + value = float(os.getenv(name, default)) + except Exception: + value = float(default) + return max(minimum, min(maximum, value)) + + +def _read_clamped_int_env(name: str, default: str, minimum: int, maximum: int) -> int: + try: + value = int(os.getenv(name, default)) + except Exception: + value = int(default) + return max(minimum, min(maximum, value)) + # OpenAI OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") @@ -65,6 +115,13 @@ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b") OLLAMA_API_URL = os.getenv( "OLLAMA_API_URL", "http://localhost:11434/v1/chat/completions" ) +AI_CHAT_TEMPERATURE = _read_clamped_float_env("AI_CHAT_TEMPERATURE", "0.9", 0.0, 2.0) +AI_CHAT_MAX_TOKENS = _read_clamped_int_env("AI_CHAT_MAX_TOKENS", "220", 80, 700) +AI_CHAT_MAX_CHARS = _read_clamped_int_env("AI_CHAT_MAX_CHARS", "320", 120, 1200) +AI_INTENT_TEMPERATURE = _read_clamped_float_env("AI_INTENT_TEMPERATURE", "0.0", 0.0, 1.0) +AI_TRANSLATION_TEMPERATURE = _read_clamped_float_env( + "AI_TRANSLATION_TEMPERATURE", "0.2", 0.0, 1.0 +) # --- Настройки распознавания речи (Deepgram) --- # Ключ для облачного STT (Speech-to-Text) @@ -86,6 +143,42 @@ WAKE_WORD_ALIASES = ( PORCUPINE_KEYWORD_PATH = BASE_DIR / "assets" / "models" / "Waltron_en_linux_v4_0_0.ppn" # Чувствительность wake word (0..1). Выше = ловит легче, но больше ложных срабатываний. PORCUPINE_SENSITIVITY = float(os.getenv("PORCUPINE_SENSITIVITY", "0.8")) +# Антифантомный фильтр wake word по RMS-сигналу. +# Чем выше WAKEWORD_MIN_RMS / WAKEWORD_RMS_MULTIPLIER, тем меньше ложных срабатываний, +# но тем выше риск не распознать очень тихую активацию. +try: + WAKEWORD_MIN_RMS = float(os.getenv("WAKEWORD_MIN_RMS", "120")) +except Exception: + WAKEWORD_MIN_RMS = 120.0 +WAKEWORD_MIN_RMS = max(0.0, WAKEWORD_MIN_RMS) +try: + WAKEWORD_RMS_MULTIPLIER = float(os.getenv("WAKEWORD_RMS_MULTIPLIER", "1.7")) +except Exception: + WAKEWORD_RMS_MULTIPLIER = 1.7 +WAKEWORD_RMS_MULTIPLIER = max(1.0, WAKEWORD_RMS_MULTIPLIER) +try: + WAKEWORD_HIT_COOLDOWN_SECONDS = float( + os.getenv("WAKEWORD_HIT_COOLDOWN_SECONDS", "1.2") + ) +except Exception: + WAKEWORD_HIT_COOLDOWN_SECONDS = 1.2 +WAKEWORD_HIT_COOLDOWN_SECONDS = max(0.0, WAKEWORD_HIT_COOLDOWN_SECONDS) +try: + WAKEWORD_REOPEN_GRACE_SECONDS = float( + os.getenv("WAKEWORD_REOPEN_GRACE_SECONDS", "0.45") + ) +except Exception: + WAKEWORD_REOPEN_GRACE_SECONDS = 0.45 +WAKEWORD_REOPEN_GRACE_SECONDS = max(0.0, WAKEWORD_REOPEN_GRACE_SECONDS) +WAKEWORD_ENABLE_FALLBACK_STT = ( + os.getenv("WAKEWORD_ENABLE_FALLBACK_STT", "0").strip().lower() + in {"1", "true", "yes", "on"} +) +# При активации wake word музыка приглушается до указанного процента от текущего уровня. +WAKEWORD_MUSIC_DUCK_PERCENT = _read_clamped_int_env( + "WAKEWORD_MUSIC_DUCK_PERCENT", "20", 1, 100 +) +WAKEWORD_MUSIC_DUCK_RATIO = WAKEWORD_MUSIC_DUCK_PERCENT / 100.0 # --- Параметры аудио --- # Частота дискретизации для микрофона (стандарт для распознавания речи) @@ -134,17 +227,17 @@ _stt_sfx_default = BASE_DIR / "assets" / "sounds" / "alisa-golosovoj-pomoschnik. if not _stt_sfx_default.exists(): _stt_sfx_default = Path.home() / "Music" / "alisa-golosovoj-pomoschnik.mp3" STT_START_SOUND_PATH = os.getenv("STT_START_SOUND_PATH", "").strip() or str(_stt_sfx_default) -try: - STT_START_SOUND_VOLUME = float(os.getenv("STT_START_SOUND_VOLUME", "0.25")) -except Exception: - STT_START_SOUND_VOLUME = 0.25 -STT_START_SOUND_VOLUME = max(0.0, min(1.0, STT_START_SOUND_VOLUME)) +# Звук старта STT всегда на 100% громкости, чтобы по уровню был как обычный TTS-ответ. +STT_START_SOUND_VOLUME = 1.0 # Голос для русского языка (eugene - мужской голос) TTS_SPEAKER = "eugene" # Доступные (ru): aidar, baya, kseniya, xenia, eugene # Голос для английского языка TTS_EN_SPEAKER = os.getenv("TTS_EN_SPEAKER", "en_0") # Частота дискретизации для воспроизведения (качество звука) TTS_SAMPLE_RATE = 48000 +# Скорость TTS: 1.0 = обычная, <1.0 = медленнее, >1.0 = быстрее. +# По умолчанию чуть медленнее для более разборчивой речи. +TTS_SPEED = _read_clamped_float_env("TTS_SPEED", "0.96", 0.85, 1.15) # --- Настройки погоды --- WEATHER_LAT = os.getenv("WEATHER_LAT") diff --git a/app/features/alarm.py b/app/features/alarm.py index ade649f..ba413bc 100644 --- a/app/features/alarm.py +++ b/app/features/alarm.py @@ -54,6 +54,16 @@ _PARTS_OF_DAY = {"утра", "дня", "вечера", "ночи"} _FILLER_WORDS = {"мне", "меня", "пожалуйста", "на", "в", "во", "к", "и"} _HOUR_WORDS = {"час", "часа", "часов"} _MINUTE_WORDS = {"минута", "минуту", "минуты", "минут"} +_ALARM_MARKERS = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"} +_ALARM_LIST_RE = re.compile( + r"\b(какие|какой|список|активн|покажи|показать|сколько|есть ли|перечисли)\b" +) +_ALARM_CANCEL_RE = re.compile( + r"\b(отмени|отмена|удали|удалить|выключи|отключи|деактивир|сбрось|очисти)\b" +) +_ALARM_CREATE_RE = re.compile( + r"\b(постав|установ|запусти|включи|разбуди|создай|добавь|измени|перенес|назнач)\b" +) def _parse_number_tokens(tokens, start_index: int): @@ -97,10 +107,9 @@ def _apply_part_of_day(hour: int, part_of_day: str | None) -> int: def _extract_alarm_time_words(text: str): tokens = re.findall(r"[a-zа-я0-9]+", text.lower().replace("ё", "е")) - markers = {"будильник", "разбуди", "поставь", "установи", "включи", "на", "в", "к"} for index, token in enumerate(tokens): - if token not in markers: + if token not in _ALARM_MARKERS: continue current = index + 1 @@ -134,6 +143,40 @@ def _extract_alarm_time_words(text: str): return None +def _extract_alarm_time(text: str): + # Формат "7:30", "7.30", "7-30" и варианты с "в/на/к". + match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text) + if match: + h, m = int(match.group(1)), int(match.group(2)) + period_match = re.search( + r"\b(?:на|в|во|к)?\s*" + + re.escape(match.group(0).strip()) + + r"\s+(утра|дня|вечера|ночи)\b", + text, + ) + part_of_day = period_match.group(1) if period_match else None + h = _apply_part_of_day(h, part_of_day) + if 0 <= h <= 23 and 0 <= m <= 59: + return h, m + + # Формат цифрами: "в 7 утра", "на 7", "к 6 30". + match_time = re.search( + r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?" + r"(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?" + r"(?:\s+(утра|дня|вечера|ночи))?\b", + text, + ) + if match_time: + h = int(match_time.group(1)) + m = int(match_time.group(2)) if match_time.group(2) else 0 + h = _apply_part_of_day(h, match_time.group(3)) + if 0 <= h <= 23 and 0 <= m <= 59: + return h, m + + # Формат словами: "в семь утра", "будильник семь тридцать". + return _extract_alarm_time_words(text) + + class AlarmClock: def __init__(self): self.alarms = [] @@ -229,7 +272,14 @@ class AlarmClock: return self.add_alarm_with_days(hour, minute, days=None) def add_alarm_with_days(self, hour: int, minute: int, days=None): - """Добавление нового будильника (или обновление существующего) с днями недели.""" + """ + Добавление нового будильника (или обновление существующего) с днями недели. + + Returns: + "created" - создан новый будильник + "reactivated" - найден существующий неактивный, включён обратно + "already_active" - такой будильник уже активен + """ days_key = self._days_key(days) for alarm in self.alarms: if ( @@ -237,11 +287,13 @@ class AlarmClock: and alarm.get("minute") == minute and self._days_key(alarm.get("days")) == days_key ): + if alarm.get("active"): + return "already_active" alarm["active"] = True alarm["days"] = days_key alarm["last_triggered"] = None self.save_alarms() - return + return "reactivated" self.alarms.append( {"hour": hour, "minute": minute, "active": True, "days": days_key} @@ -250,6 +302,7 @@ class AlarmClock: days_phrase = self._format_days_phrase(days_key) suffix = f" {days_phrase}" if days_phrase else "" print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}") + return "created" def cancel_all_alarms(self): """Выключение (деактивация) всех будильников.""" @@ -258,6 +311,33 @@ class AlarmClock: self.save_alarms() print("🔕 Все будильники отменены.") + def remove_alarms(self, hour: int, minute: int, days=None) -> int: + """ + Удаляет будильники по времени. + Если переданы days, удаляются только будильники с совпадающими днями. + """ + days_key = self._days_key(days) + kept = [] + removed = 0 + + for alarm in self.alarms: + alarm_hour = alarm.get("hour") + alarm_minute = alarm.get("minute") + if alarm_hour != hour or alarm_minute != minute: + kept.append(alarm) + continue + + if days_key is not None and self._days_key(alarm.get("days")) != days_key: + kept.append(alarm) + continue + + removed += 1 + + if removed: + self.alarms = kept + self.save_alarms() + return removed + def describe_alarms(self) -> str: """Возвращает текстовое описание активных будильников.""" active = [ @@ -365,73 +445,60 @@ class AlarmClock: def parse_command(self, text: str) -> str | None: """ - Парсинг команды установки будильника из текста. - Примеры: "разбуди в 7:30", "будильник на 8 утра". + Парсинг команд управления будильниками. + Примеры: "разбуди в 7:30", "удали будильник на 8:00", "какие будильники". """ - text = replace_roman_numerals(text.lower()) - if "будильник" not in text and "разбуди" not in text: + text = replace_roman_numerals(text.lower().replace("ё", "е")) + if not re.search(r"\b(будильник\w*|разбуд\w*)\b", text): return None - if "будильник" in text and re.search( - r"(какие|какой|список|активн|покажи|сколько|есть ли)", text - ): + if _ALARM_LIST_RE.search(text): return self.describe_alarms() - if "отмени" in text: - self.cancel_all_alarms() - return "Хорошо, я отменил все будильники." + if _ALARM_CANCEL_RE.search(text): + cancel_time = _extract_alarm_time(text) + cancel_days = self._extract_alarm_days(text) + if cancel_time: + h, m = cancel_time + removed = self.remove_alarms(h, m, days=cancel_days) + if removed: + days_phrase = self._format_days_phrase(cancel_days) + suffix = f" {days_phrase}" if days_phrase else "" + return f"Удалил {removed} будильник(а) на {h:02d}:{m:02d}{suffix}." + return f"Не нашел будильник на {h:02d}:{m:02d}." + + if re.search(r"\b(все|всех)\b", text) or "будильники" in text: + self.cancel_all_alarms() + return "Хорошо, я отменил все будильники." + + return ( + "Скажите время будильника, который нужно удалить. " + "Например: удалите будильник на 7:30." + ) days = self._extract_alarm_days(text) - - # Поиск формата "7:30", "7.30" и вариантов с "в/на/к". - match = re.search(r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})[:.-](\d{2})\b", text) - if match: - h, m = int(match.group(1)), int(match.group(2)) - period_match = re.search( - r"\b(?:на|в|во|к)?\s*" + re.escape(match.group(0).strip()) + r"\s+(утра|дня|вечера|ночи)\b", - text, - ) - part_of_day = period_match.group(1) if period_match else None - h = _apply_part_of_day(h, part_of_day) - if 0 <= h <= 23 and 0 <= m <= 59: - self.add_alarm_with_days(h, m, days=days) - days_phrase = self._format_days_phrase(days) - suffix = f" {days_phrase}" if days_phrase else "" - return f"Я установил будильник на {h} часов {m} минут{suffix}." - - # Поиск формата цифрами: "в 7 утра", "на 7", "к 6 30" - match_time = re.search( - r"(?:\b(?:на|в|во|к)\s+)?(\d{1,2})(?:\s*(?:часов|часа|час))?(?:\s+(\d{1,2})(?:\s*(?:минут|минуты|минута))?)?(?:\s+(утра|дня|вечера|ночи))?\b", - text, - ) - - if match_time: - h = int(match_time.group(1)) - m = int(match_time.group(2)) if match_time.group(2) else 0 - h = _apply_part_of_day(h, match_time.group(3)) - - if 0 <= h <= 23 and 0 <= m <= 59: - self.add_alarm_with_days(h, m, days=days) - days_phrase = self._format_days_phrase(days) - suffix = f" {days_phrase}" if days_phrase else "" - return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}." - - # Поиск формата словами: "в семь утра", "будильник семь тридцать" - word_time = _extract_alarm_time_words(text) - if word_time: - h, m = word_time - self.add_alarm_with_days(h, m, days=days) + alarm_time = _extract_alarm_time(text) + if alarm_time: + h, m = alarm_time + add_status = self.add_alarm_with_days(h, m, days=days) + if add_status == "already_active": + return "Такой будильник уже установлен." days_phrase = self._format_days_phrase(days) suffix = f" {days_phrase}" if days_phrase else "" return f"Хорошо, разбужу вас в {h}:{m:02d}{suffix}." - if re.search(r"(постав|установ|запусти|включи|разбуди)", text) or text.strip() in { + if _ALARM_CREATE_RE.search(text) or text.strip() in { "будильник", "поставь будильник", + "создай будильник", + "добавь будильник", }: return ASK_ALARM_TIME_PROMPT - return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'." + return ( + "Я не понял команду для будильника. " + "Скажите, например: поставь на 7:30, покажи будильники или удали будильник на 7:30." + ) # Глобальный экземпляр diff --git a/app/features/music.py b/app/features/music.py index a0009d9..bf2ae00 100644 --- a/app/features/music.py +++ b/app/features/music.py @@ -20,7 +20,7 @@ from urllib.parse import urlencode import requests -from ..core.config import BASE_DIR, WAKE_WORD_ALIASES +from ..core.config import BASE_DIR, WAKE_WORD_ALIASES, WAKEWORD_MUSIC_DUCK_RATIO try: import spotipy @@ -97,6 +97,8 @@ class SpotifyProvider: self.sp = None self._initialized = False self._init_error = None + self._duck_prev_volume: Optional[int] = None + self._duck_active = False def initialize(self) -> bool: if self._initialized: @@ -249,6 +251,71 @@ class SpotifyProvider: except Exception as exc: return f"Spotify: {exc}" + def duck_volume(self, ratio: float) -> bool: + if self._duck_active: + return True + if not self._ensure_initialized(): + return False + + try: + current = self.sp.current_playback() + except Exception: + return False + + if not current or not current.get("is_playing"): + return False + + device = current.get("device") or {} + device_id = device.get("id") + volume_percent = device.get("volume_percent") + if volume_percent is None: + return False + + try: + current_volume = int(volume_percent) + except Exception: + return False + + target_volume = int(round(current_volume * float(ratio))) + target_volume = max(0, min(100, target_volume)) + if target_volume >= current_volume: + target_volume = max(0, current_volume - 1) + + try: + self.sp.volume(target_volume, device_id=device_id) + except Exception: + return False + + self._duck_prev_volume = current_volume + self._duck_active = True + return True + + def restore_duck_volume(self) -> bool: + if not self._duck_active: + return False + + previous_volume = self._duck_prev_volume + self._duck_prev_volume = None + self._duck_active = False + + if previous_volume is None: + return False + if not self._ensure_initialized(): + return False + + try: + current = self.sp.current_playback() or {} + device = current.get("device") or {} + device_id = device.get("id") + self.sp.volume(int(max(0, min(100, previous_volume))), device_id=device_id) + return True + except Exception: + return False + + def clear_duck_state(self) -> None: + self._duck_prev_volume = None + self._duck_active = False + class NavidromeProvider: """Primary provider using Navidrome + MPV IPC.""" @@ -269,6 +336,8 @@ class NavidromeProvider: self._folder_index_built_at = 0.0 self._autonext_lock = threading.Lock() self._autonext_suppress_until = 0.0 + self._duck_prev_volume: Optional[float] = None + self._duck_active = False self._snapshot_stop = threading.Event() self._snapshot_thread = threading.Thread( @@ -764,6 +833,53 @@ class NavidromeProvider: self._set_state(paused=paused, position_sec=max(0.0, position)) + def duck_volume(self, ratio: float) -> bool: + if self._duck_active: + return True + if not self._is_mpv_alive(): + return False + + try: + paused = bool(self._mpv_ipc(["get_property", "pause"])) + if paused: + return False + current_volume = float(self._mpv_ipc(["get_property", "volume"]) or 100.0) + target_volume = max(0.0, min(100.0, current_volume * float(ratio))) + if target_volume >= current_volume: + target_volume = max(0.0, current_volume - 1.0) + self._mpv_ipc(["set_property", "volume", target_volume]) + except Exception: + return False + + self._duck_prev_volume = current_volume + self._duck_active = True + return True + + def restore_duck_volume(self) -> bool: + if not self._duck_active: + return False + + previous_volume = self._duck_prev_volume + self._duck_prev_volume = None + self._duck_active = False + + if previous_volume is None: + return False + if not self._is_mpv_alive(): + return False + + try: + self._mpv_ipc( + ["set_property", "volume", max(0.0, min(100.0, float(previous_volume)))] + ) + return True + except Exception: + return False + + def clear_duck_state(self) -> None: + self._duck_prev_volume = None + self._duck_active = False + def _snapshot_loop(self) -> None: while not self._snapshot_stop.wait(1.0): try: @@ -793,6 +909,7 @@ class NavidromeProvider: self._mpv_process = None self._remove_stale_socket() + self.clear_duck_state() def _start_song(self, song: Song, start_sec: float = 0.0) -> None: self._ensure_initialized() @@ -1024,6 +1141,8 @@ class MusicController: def __init__(self) -> None: self.navidrome = NavidromeProvider() self.spotify = SpotifyProvider() + self._wakeword_duck_ratio = max(0.01, min(1.0, float(WAKEWORD_MUSIC_DUCK_RATIO))) + self._duck_active_provider: Optional[str] = None aliases = sorted( { alias.lower().replace("ё", "е").strip() @@ -1058,6 +1177,41 @@ class MusicController: f" (Причина: {exc})" ) + def _play_default(self) -> str: + """Default voice command ("включи музыку") with provider fallback.""" + return self._with_fallback( + lambda: self.navidrome.play_random(contextual_resume=True), + lambda: self.spotify.play_music(), + ) + + def duck_for_wakeword(self) -> bool: + if self._duck_active_provider in {"navidrome", "spotify"}: + return True + if self._wakeword_duck_ratio >= 1.0: + return False + + if self.navidrome.duck_volume(self._wakeword_duck_ratio): + self._duck_active_provider = "navidrome" + return True + if self.spotify.duck_volume(self._wakeword_duck_ratio): + self._duck_active_provider = "spotify" + return True + return False + + def restore_after_wakeword(self) -> bool: + provider = self._duck_active_provider + self._duck_active_provider = None + + if provider == "navidrome": + return self.navidrome.restore_duck_volume() + if provider == "spotify": + return self.spotify.restore_duck_volume() + + # Защитная очистка на случай рассинхронизации состояния. + self.navidrome.clear_duck_state() + self.spotify.clear_duck_state() + return False + def pause_for_stop_word(self) -> Optional[str]: """ Pause music for generic stop-words ("стоп", "хватит", etc). @@ -1187,10 +1341,7 @@ class MusicController: lambda: self.navidrome.play_query(normalized_query), lambda: self.spotify.play_music(normalized_query), ) - return self._with_fallback( - lambda: self.navidrome.play_random(contextual_resume=True), - lambda: self.spotify.play_music(None), - ) + return self._play_default() if normalized_action in {"play_query", "search"}: if not normalized_query: @@ -1286,10 +1437,7 @@ class MusicController: lambda: self.navidrome.play_query(play_query), lambda: self.spotify.play_music(play_query), ) - return self._with_fallback( - lambda: self.navidrome.play_random(contextual_resume=True), - lambda: self.spotify.play_music(None), - ) + return self._play_default() return None diff --git a/app/features/weather.py b/app/features/weather.py index fc0e105..fd9e632 100644 --- a/app/features/weather.py +++ b/app/features/weather.py @@ -3,11 +3,120 @@ Weather feature module. Fetches weather data from Open-Meteo API. """ +import re import requests from datetime import datetime from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY _HTTP = requests.Session() +_CITY_PREFIX_RE = re.compile( + r"^(?:в|во)\s+(?:город(?:е|у)?\s+)?", + flags=re.IGNORECASE, +) +_CITY_SPACING_RE = re.compile(r"\s+") +_KNOWN_CITY_VARIATIONS = { + "нью йорк": "Нью-Йорк", + "нью-йорк": "Нью-Йорк", + "нью йорке": "Нью-Йорк", + "нью-йорке": "Нью-Йорк", + "нью йорка": "Нью-Йорк", + "нью-йорка": "Нью-Йорк", + "нью йорком": "Нью-Йорк", + "нью-йорком": "Нью-Йорк", + "санкт петербург": "Санкт-Петербург", + "санкт-петербург": "Санкт-Петербург", + "санкт петербурге": "Санкт-Петербург", + "санкт-петербурге": "Санкт-Петербург", + "санкт петербурга": "Санкт-Петербург", + "санкт-петербурга": "Санкт-Петербург", + "санкт петербургом": "Санкт-Петербург", + "санкт-петербургом": "Санкт-Петербург", + "нижний новгород": "Нижний Новгород", + "нижнем новгороде": "Нижний Новгород", + "нижнего новгорода": "Нижний Новгород", + "ростов на дону": "Ростов-на-Дону", + "ростове на дону": "Ростов-на-Дону", + "ростова на дону": "Ростов-на-Дону", + "лос анджелес": "Лос-Анджелес", + "лос-анджелес": "Лос-Анджелес", + "лос анджелесе": "Лос-Анджелес", + "лос-анджелесе": "Лос-Анджелес", + "сан франциско": "Сан-Франциско", + "сан-франциско": "Сан-Франциско", + "улан удэ": "Улан-Удэ", + "улан-удэ": "Улан-Удэ", +} +_SINGLE_WORD_CITY_VARIATIONS = { + "москве": "Москва", + "москвы": "Москва", + "москвой": "Москва", + "москву": "Москва", + "лондоне": "Лондон", + "лондона": "Лондон", + "лондоном": "Лондон", + "париже": "Париж", + "парижа": "Париж", + "парижем": "Париж", + "берлине": "Берлин", + "берлина": "Берлин", + "берлином": "Берлин", + "пекине": "Пекин", + "пекина": "Пекин", + "пекином": "Пекин", + "роме": "Рим", + "рима": "Рим", + "римом": "Рим", + "мадриде": "Мадрид", + "мадрида": "Мадрид", + "мадридом": "Мадрид", + "сиднее": "Сидней", + "сиднея": "Сидней", + "сиднеем": "Сидней", + "вашингтоне": "Вашингтон", + "вашингтона": "Вашингтон", + "вашингтоном": "Вашингтон", + "сиэтле": "Сиэтл", + "сиэтла": "Сиэтл", + "сиэтлом": "Сиэтл", + "бостоне": "Бостон", + "бостона": "Бостон", + "бостоном": "Бостон", + "денвере": "Денвер", + "денвера": "Денвер", + "денвером": "Денвер", + "хьюстоне": "Хьюстон", + "хьюстона": "Хьюстон", + "хьюстоном": "Хьюстон", + "фениксе": "Феникс", + "феникса": "Феникс", + "фениксом": "Феникс", + "атланте": "Атланта", + "атланты": "Атланта", + "атлантой": "Атланта", + "портленде": "Портленд", + "портленда": "Портленд", + "портлендом": "Портленд", + "остине": "Остин", + "остина": "Остин", + "остином": "Остин", + "нэшвилле": "Нэшвилл", + "нэшвилла": "Нэшвилл", + "нэшвиллом": "Нэшвилл", + "токио": "Токио", + "торонто": "Торонто", + "чикаго": "Чикаго", + "майами": "Майами", +} + + +def _smart_title_city(text: str) -> str: + parts = [] + for word in text.split(): + hyphen_parts = [part.capitalize() for part in word.split("-") if part] + parts.append("-".join(hyphen_parts)) + return " ".join(parts) + + def get_wmo_description(code: int) -> str: """Decodes WMO weather code to Russian description.""" codes = { @@ -72,143 +181,45 @@ 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) + lowered = str(city_name or "").lower().replace("ё", "е").strip() + if not lowered: + return city_name + + lowered = _CITY_PREFIX_RE.sub("", lowered) + lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -") + if not lowered: + return city_name + + exact_match = _KNOWN_CITY_VARIATIONS.get(lowered) + if exact_match: + return exact_match + + single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered) + if single_word_match: + return single_word_match + + spaced = lowered.replace("-", " ") + exact_match = _KNOWN_CITY_VARIATIONS.get(spaced) + if exact_match: + return exact_match + + if " " not in spaced: + for suffix, replacement in ( + ("ом", ""), + ("ем", ""), + ("ой", "а"), + ("ей", "а"), + ("е", ""), + ("у", "а"), + ("ю", "я"), + ): + if spaced.endswith(suffix) and len(spaced) > len(suffix) + 2: + candidate = spaced[: -len(suffix)] + replacement + mapped = _SINGLE_WORD_CITY_VARIATIONS.get(candidate) + if mapped: + return mapped + + return _smart_title_city(lowered) def get_coordinates_by_city(city_name: str) -> tuple: """ @@ -220,8 +231,9 @@ def get_coordinates_by_city(city_name: str) -> tuple: # Add normalized version normalized_city = normalize_city_name(city_name) - if normalized_city != city_name: + if normalized_city and normalized_city not in try_names: try_names.append(normalized_city) + normalized_lower = str(normalized_city or city_name).lower().replace("ё", "е").strip() # Also try with English version if it's a known translation city_to_eng = { @@ -334,8 +346,18 @@ def get_coordinates_by_city(city_name: str) -> tuple: } eng_name = city_to_eng.get(city_name.lower()) - if eng_name: + normalized_eng_name = city_to_eng.get(normalized_lower) + if eng_name and eng_name not in try_names: try_names.append(eng_name) + if normalized_eng_name and normalized_eng_name not in try_names: + try_names.append(normalized_eng_name) + + if normalized_city: + hyphen_variant = normalized_city.replace(" ", "-") + space_variant = normalized_city.replace("-", " ") + for variant in (hyphen_variant, space_variant): + if variant and variant not in try_names: + try_names.append(variant) # Try each name in sequence for name_to_try in try_names: diff --git a/app/main.py b/app/main.py index eb6a48d..897fc0a 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ import re import signal import sys import time +from datetime import datetime from collections import deque from pathlib import Path import subprocess @@ -22,11 +23,12 @@ else: _MIXER_IMPORT_ERROR = None # Наши модули -from .audio.sound_level import parse_volume_text, set_volume +from .audio.sound_level import is_volume_command, parse_volume_text, set_volume from .audio.stt import cleanup as cleanup_stt from .audio.stt import get_recognizer, listen from .audio.tts import initialize as init_tts from .audio.tts import speak +from .audio.tts import was_interrupted as was_tts_interrupted from .audio.wakeword import ( check_wakeword_once, wait_for_wakeword, @@ -43,6 +45,7 @@ from .core.config import ( STT_START_SOUND_PATH, STT_START_SOUND_VOLUME, WAKE_WORD, + WAKE_WORD_ALIASES, ) from .core.cleaner import clean_response from .core.commands import is_stop_command @@ -89,6 +92,50 @@ _TRANSLATION_COMMANDS = [ _TRANSLATION_COMMANDS_SORTED = sorted( _TRANSLATION_COMMANDS, key=lambda item: len(item[0]), reverse=True ) +_TRANSLATION_QUERY_RULES = [ + ( + re.compile( + r"^как\s+перевод(?:ится|ить)\s+(.+?)\s+с\s+английского(?:\s+на\s+русский)?[?.!]*$", + re.IGNORECASE, + ), + "en", + "ru", + ), + ( + re.compile( + r"^как\s+перевод(?:ится|ить)\s+(.+?)\s+с\s+русского(?:\s+на\s+английский)?[?.!]*$", + re.IGNORECASE, + ), + "ru", + "en", + ), + ( + re.compile( + r"^как\s+будет\s+(.+?)\s+на\s+английском(?:\s+языке)?[?.!]*$", + re.IGNORECASE, + ), + "ru", + "en", + ), + ( + re.compile( + r"^как\s+будет\s+(.+?)\s+на\s+русском(?:\s+языке)?[?.!]*$", + re.IGNORECASE, + ), + "en", + "ru", + ), + ( + re.compile(r"^что\s+значит\s+(.+?)\s+по[-\s]?английски[?.!]*$", re.IGNORECASE), + "ru", + "en", + ), + ( + re.compile(r"^что\s+значит\s+(.+?)\s+по[-\s]?русски[?.!]*$", re.IGNORECASE), + "en", + "ru", + ), +] _REPEAT_PHRASES = { "еще раз", @@ -109,6 +156,8 @@ _REPEAT_PHRASES = { _WEATHER_TRIGGERS = ( "погода", "погоду", + "погоде", + "по погоде", "что на улице", "какая температура", "сколько градусов", @@ -117,6 +166,7 @@ _WEATHER_TRIGGERS = ( "нужен ли зонт", "брать ли зонт", "прогноз погоды", + "прогноз", "че там на улице", "что там на улице", "как на улице", @@ -151,27 +201,64 @@ _CITY_INVALID_WORDS = { _CITY_PATTERNS = [ re.compile( - r"в\s+городе\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", + r"\b(?:в|во)\s+город(?:е|у)?\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})", re.IGNORECASE, ), re.compile( - r"в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", + r"\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})", re.IGNORECASE, ), re.compile( - r"погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", + r"\bпогода\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})", re.IGNORECASE, ), re.compile( - r"погода\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)\s+(?:какая|сейчас|там)", + r"\bпогода\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})\s+(?:какая|сейчас|там|сегодня|завтра)\b", re.IGNORECASE, ), re.compile( - r"(?:какая|как)\s+погода\s+в\s+([а-яёa-z]+[-\s]*[а-яёa-z]*(?:[-\s]+[а-яёa-z]+)*)", + r"\b(?:какая|как)\s+(?:сейчас\s+)?погода\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})", + re.IGNORECASE, + ), + re.compile( + r"\b(?:какой|какова)\s+прогноз(?:\s+погоды)?\s+\b(?:в|во)\s+([а-яёa-z][а-яёa-z-]*(?:\s+[а-яёa-z][а-яёa-z-]*){0,3})", re.IGNORECASE, ), ] +_WEATHER_TRIGGER_PATTERNS = [ + re.compile(r"\bпогод(?:а|у|е|ы|ой)\b", re.IGNORECASE), + re.compile(r"\bпрогноз(?:\s+погоды)?\b", re.IGNORECASE), + re.compile(r"\b(?:что|как|че)\s+там\s+на\s+улице\b", re.IGNORECASE), + re.compile(r"\b(?:какая\s+температура|сколько\s+градусов)\b", re.IGNORECASE), + re.compile(r"\b(?:нужен|брать)\s+ли\s+зонт\b", re.IGNORECASE), +] + +_TIME_TRIGGER_PATTERNS = [ + re.compile(r"\bкотор(ый|ого)\s+час\b", re.IGNORECASE), + re.compile(r"\bсколько\s+времени\b", re.IGNORECASE), + re.compile(r"\bкакое\s+сейчас\s+время\b", re.IGNORECASE), + re.compile(r"\bкоторый\s+сейчас\s+час\b", re.IGNORECASE), + re.compile(r"\bwhat\s+time\b", re.IGNORECASE), + re.compile(r"\bcurrent\s+time\b", re.IGNORECASE), + re.compile(r"\btime\s+is\s+it\b", re.IGNORECASE), +] + +_WAKEWORD_PREFIX_RE = re.compile( + rf"^(?:{'|'.join(re.escape(alias) for alias in sorted({WAKE_WORD.lower(), *WAKE_WORD_ALIASES}, key=len, reverse=True))})(?:[\s,.:;!?-]+|$)", + re.IGNORECASE, +) + +_CITY_TRAILING_STOP_WORDS = { + "сейчас", + "сегодня", + "завтра", + "там", + "теперь", + "вообще", + "пожалуйста", +} + _SEMANTIC_INTENT_MIN_CONFIDENCE = 0.55 _SEMANTIC_MUSIC_MIN_CONFIDENCE = 0.45 _SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE = 0.72 @@ -194,8 +281,10 @@ def signal_handler(sig, frame): def parse_translation_request(text: str): """Проверяет, является ли фраза запросом на перевод.""" - text_lower = text.lower().strip() - text_lower = text.lower().strip() + text = str(text or "").strip() + if not text: + return None + text_lower = text.lower() # Список префиксов команд перевода и соответствующих направлений языков. # Важно: более длинные префиксы должны проверяться первыми (например, # "переведи с русского на английский" не должен схватиться как "переведи с русского"). @@ -209,9 +298,134 @@ def parse_translation_request(text: str): "target_lang": target_lang, "text": rest, } + + def _clean_payload(raw: str) -> str: + return str(raw or "").strip().lstrip(" :—-").strip(" \"'“”«»") + + for pattern, source_lang, target_lang in _TRANSLATION_QUERY_RULES: + match = pattern.match(text) + if not match: + continue + payload = _clean_payload(match.group(1)) + if not payload: + return None + return { + "source_lang": source_lang, + "target_lang": target_lang, + "text": payload, + } + + # Универсальный fallback: "переведи <текст>" / "translate ". + generic_match = re.match( + r"^(?:переведи|перевод|translate)\s+(.+)$", + text, + flags=re.IGNORECASE, + ) + if generic_match: + payload = _clean_payload(generic_match.group(1)) + if not payload: + return None + + # Явное направление, если есть. + if re.search(r"\b(?:на|в|по)\s+англий", text_lower): + source_lang, target_lang = "ru", "en" + payload = re.sub( + r"\s+(?:на|в|по)\s+англий(?:ский|ском|ски)(?:\s+язык(?:е)?)?\s*$", + "", + payload, + flags=re.IGNORECASE, + ).strip() + elif re.search(r"\b(?:на|в|по)\s+рус", text_lower): + source_lang, target_lang = "en", "ru" + payload = re.sub( + r"\s+(?:на|в|по)\s+рус(?:ский|ском|ски)(?:\s+язык(?:е)?)?\s*$", + "", + payload, + flags=re.IGNORECASE, + ).strip() + else: + has_cyrillic = bool(re.search(r"[а-яё]", payload, flags=re.IGNORECASE)) + has_latin = bool(re.search(r"[a-z]", payload, flags=re.IGNORECASE)) + if has_latin and not has_cyrillic: + source_lang, target_lang = "en", "ru" + elif has_cyrillic and not has_latin: + source_lang, target_lang = "ru", "en" + elif has_latin and has_cyrillic: + source_lang, target_lang = "en", "ru" + else: + source_lang, target_lang = "ru", "en" + + if not payload: + return None + + return { + "source_lang": source_lang, + "target_lang": target_lang, + "text": payload, + } + return None +def _strip_wakeword_prefix(text: str) -> str: + normalized = str(text or "").strip() + if not normalized: + return "" + return _WAKEWORD_PREFIX_RE.sub("", normalized, count=1).strip() + + +def _extract_requested_city(text: str) -> str | None: + lowered = _strip_wakeword_prefix(text).lower().replace("ё", "е") + lowered = re.sub(r"[.!?,;:…]+", " ", lowered) + lowered = re.sub(r"\s+", " ", lowered).strip() + if not lowered: + return None + + for pattern in _CITY_PATTERNS: + match = pattern.search(lowered) + if not match: + continue + + candidate = match.group(1).strip(" -") + if not candidate: + continue + + parts = [part for part in candidate.split() if part] + while parts and parts[-1] in _CITY_TRAILING_STOP_WORDS: + parts.pop() + while parts and parts[0] in _CITY_INVALID_WORDS: + parts.pop(0) + + if not parts: + continue + if any(part in _CITY_INVALID_WORDS for part in parts): + continue + + cleaned_candidate = " ".join(parts) + if len(cleaned_candidate) <= 1: + continue + return cleaned_candidate + + return None + + +def _is_time_request(text: str) -> bool: + cleaned = _strip_wakeword_prefix(text).strip() + if not cleaned: + return False + for pattern in _TIME_TRIGGER_PATTERNS: + if pattern.search(cleaned): + return True + return False + + +def _has_weather_trigger(text: str) -> bool: + lowered = _strip_wakeword_prefix(text).lower().replace("ё", "е") + if any(trigger in lowered for trigger in _WEATHER_TRIGGERS): + return True + return any(pattern.search(lowered) for pattern in _WEATHER_TRIGGER_PATTERNS) + + def main(): """Точка входа.""" print("=" * 50) @@ -295,12 +509,129 @@ def main(): pass return _play_stt_start_sfx_fallback() - get_recognizer().initialize() # Подключение к Deepgram + text_mode = False + text_mode_reason = "" + response_wakeword_interrupted = False + play_followup_activation_sfx = False + output_interrupt_guard_seconds = 0.0 + audio_settle_after_tts_seconds = 0.35 + last_tts_finished_at = 0.0 + wake_interrupt_hits_required = 1 + wake_interrupt_hit_window_seconds = 0.22 + + try: + get_recognizer().initialize() # Подключение к Deepgram + except Exception as exc: + # На некоторых системах PipeWire/Pulse доступны, но PortAudio не видит вход. + # Чтобы ассистент не падал, включаем текстовый режим через stdin. + lowered = str(exc).lower() + if "no input devices found" in lowered or "audio input initialization failed" in lowered: + text_mode = True + text_mode_reason = str(exc) + print("⚠️ Микрофон недоступен для PortAudio. Включен текстовый режим.") + print(f" Причина: {text_mode_reason}") + print(" Вводите команды в терминале (без wake word).") + else: + raise + + def output_response( + text: str, + check_interrupt=None, + language: str = "ru", + allow_wake_interrupt: bool = True, + interrupt_guard_seconds: float | None = None, + ) -> bool: + nonlocal response_wakeword_interrupted + nonlocal play_followup_activation_sfx + nonlocal last_tts_finished_at + if text_mode: + cleaned = clean_response(text, language=language) + if cleaned: + print(f"🤖 {cleaned}") + return True + + if check_interrupt is not None: + effective_interrupt = check_interrupt + elif allow_wake_interrupt: + effective_interrupt = check_wakeword_once + else: + effective_interrupt = None + if effective_interrupt is None: + # Важно: None внутри TTS включает дефолтный wakeword-checker. + # Здесь нужно полностью отключить прерывания. + completed = speak( + text, + check_interrupt=lambda: False, + language=language, + ) + last_tts_finished_at = time.monotonic() + return completed + + guard_seconds = output_interrupt_guard_seconds + if interrupt_guard_seconds is not None: + try: + guard_seconds = max(0.0, float(interrupt_guard_seconds)) + except (TypeError, ValueError): + guard_seconds = output_interrupt_guard_seconds + + arm_interrupt_at = time.monotonic() + guard_seconds + wake_hits = 0 + wake_last_hit_at = 0.0 + + def guarded_interrupt(): + nonlocal wake_hits, wake_last_hit_at + if time.monotonic() < arm_interrupt_at: + return False + + try: + detected = bool(effective_interrupt()) + except Exception: + return False + + if not detected: + if ( + wake_last_hit_at > 0 + and time.monotonic() - wake_last_hit_at + > wake_interrupt_hit_window_seconds + ): + wake_hits = 0 + wake_last_hit_at = 0.0 + return False + + now = time.monotonic() + if ( + wake_last_hit_at > 0 + and now - wake_last_hit_at <= wake_interrupt_hit_window_seconds + ): + wake_hits += 1 + else: + wake_hits = 1 + wake_last_hit_at = now + + return wake_hits >= wake_interrupt_hits_required + + completed = speak(text, check_interrupt=guarded_interrupt, language=language) + last_tts_finished_at = time.monotonic() + if not completed and was_tts_interrupted(): + response_wakeword_interrupted = True + play_followup_activation_sfx = True + return completed + + def settle_audio_after_tts() -> None: + nonlocal last_tts_finished_at + if last_tts_finished_at <= 0: + return + elapsed = time.monotonic() - last_tts_finished_at + remaining = audio_settle_after_tts_seconds - elapsed + if remaining > 0: + time.sleep(remaining) + init_tts() # Загрузка нейросети для синтеза речи (Silero) alarm_clock = get_alarm_clock() # Загрузка будильников stopwatch_manager = get_stopwatch_manager() # Загрузка секундомеров timer_manager = get_timer_manager() # Загрузка таймеров cities_game = get_cities_game() # Игра "Города" + music_controller = get_music_controller() # Контроллер музыки print() # История чата @@ -331,9 +662,18 @@ def main(): last_stt_check = time.time() except Exception as e: print(f"Ошибка при проверке STT: {e}") + wakeword_music_ducked = False try: - # Освобождаем микрофон wake word - stop_wakeword_monitoring() + if text_mode: + try: + user_text = input("⌨️ Команда> ").strip() + except EOFError: + print("\nВвод закрыт, завершаю работу.") + break + except KeyboardInterrupt: + signal_handler(None, None) + if not user_text: + continue # Проверяем таймеры if timer_manager.check_timers(): @@ -345,58 +685,85 @@ def main(): skip_wakeword = False continue - # Ждем wake word - if not skip_wakeword: - detected = wait_for_wakeword(timeout=0.5) + if not text_mode: + if response_wakeword_interrupted: + skip_wakeword = True + response_wakeword_interrupted = False + wakeword_music_ducked = music_controller.duck_for_wakeword() - # Если время вышло — проверяем будильники - if not detected: - continue + # Ждем wake word + if not skip_wakeword: + detected = wait_for_wakeword(timeout=0.5) - # Звук активации - play_stt_start_sfx() + # Если время вышло — проверяем будильники + if not detected: + continue - # Слушаем команду - try: - user_text = listen(timeout_seconds=5.0, fast_stop=True) - except Exception as e: - print(f"Ошибка при прослушивании: {e}") - print("Переинициализация STT...") + wakeword_music_ducked = music_controller.duck_for_wakeword() + + # Звук активации + play_stt_start_sfx() + + # Слушаем команду try: - cleanup_stt() - get_recognizer().initialize() - except Exception as init_error: - print(f"Ошибка переинициализации STT: {init_error}") - continue # Продолжаем цикл - else: - # Follow-up режим — без wake word - print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...") - try: - user_text = listen( - timeout_seconds=7.0, - detection_timeout=followup_idle_timeout_seconds, - fast_stop=True, - ) - except Exception as e: - print(f"Ошибка при прослушивании: {e}") - print("Переинициализация STT...") + stop_wakeword_monitoring() + settle_audio_after_tts() + user_text = listen( + timeout_seconds=5.0, + detection_timeout=7.0, + fast_stop=True, + ) + 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 режим — без wake word + print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...") try: - cleanup_stt() - get_recognizer().initialize() - except Exception as init_error: - print(f"Ошибка переинициализации STT: {init_error}") - skip_wakeword = False - continue + stop_wakeword_monitoring() + settle_audio_after_tts() + # Тот же сигнал, что используется при wake word: + # теперь всегда перед стартом STT в продолжении диалога. + play_stt_start_sfx() + play_followup_activation_sfx = False + user_text = listen( + timeout_seconds=7.0, + detection_timeout=followup_idle_timeout_seconds, + fast_stop=True, + ) + 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: - # Молчание — возвращаемся к ожиданию - print("user was not talking") - skip_wakeword = False - continue + if not user_text: + # Молчание — возвращаемся к ожиданию + print("user was not talking") + skip_wakeword = False + continue # Анализ текста if not user_text: - skip_wakeword = False + if not text_mode: + output_response( + "Не расслышал, повторите, пожалуйста.", + allow_wake_interrupt=False, + ) + skip_wakeword = True + else: + skip_wakeword = False continue # Проверка на команду "Стоп" @@ -411,7 +778,7 @@ def main(): clean_stopwatch_stop_response = clean_response( stopwatch_stop_response, language="ru" ) - speak(clean_stopwatch_stop_response) + output_response(clean_stopwatch_stop_response) last_response = clean_stopwatch_stop_response skip_wakeword = False continue @@ -428,9 +795,9 @@ def main(): ): if last_response: print(f"🔁 Повторяю: {last_response}") - speak(last_response) + output_response(last_response) else: - speak("Я еще ничего не говорил.") + output_response("Я еще ничего не говорил.") skip_wakeword = True continue @@ -463,7 +830,7 @@ def main(): clean_stopwatch_stop_response = clean_response( stopwatch_stop_response, language="ru" ) - speak(clean_stopwatch_stop_response) + output_response(clean_stopwatch_stop_response) last_response = clean_stopwatch_stop_response skip_wakeword = False continue @@ -478,9 +845,9 @@ def main(): ): if last_response: print(f"🔁 Повторяю: {last_response}") - speak(last_response) + output_response(last_response) else: - speak("Я еще ничего не говорил.") + output_response("Я еще ничего не говорил.") skip_wakeword = True continue @@ -497,7 +864,7 @@ def main(): clean_music_response = clean_response( semantic_music_response, language="ru" ) - speak(clean_music_response) + output_response(clean_music_response) last_response = clean_music_response skip_wakeword = True continue @@ -523,12 +890,12 @@ def main(): smalltalk_response = get_smalltalk_response(effective_text) if smalltalk_response: clean_smalltalk = clean_response(smalltalk_response, language="ru") - speak(clean_smalltalk) + output_response(clean_smalltalk) last_response = clean_smalltalk skip_wakeword = True continue - command_text = effective_text + command_text = _strip_wakeword_prefix(effective_text) or effective_text command_text_lower = command_text.lower() if pending_time_target == "timer" and "таймер" not in command_text_lower: command_text = f"таймер {command_text}" @@ -538,6 +905,15 @@ def main(): and "разбуди" not in command_text_lower ): command_text = f"будильник {command_text}" + elif ( + semantic_type == "alarm" + and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE + and re.search(r"\b(будильник\w*|разбуд\w*)\b", command_text_lower) + is None + ): + # Для AI-нормализованных фраз без явного слова "будильник" + # добавляем маркер, чтобы гарантированно пройти в alarm parser. + command_text = f"будильник {command_text}" # Таймеры stopwatch_response = stopwatch_manager.parse_command(command_text) @@ -545,7 +921,7 @@ def main(): clean_stopwatch_response = clean_response( stopwatch_response, language="ru" ) - speak(clean_stopwatch_response) + output_response(clean_stopwatch_response) last_response = clean_stopwatch_response skip_wakeword = True continue @@ -554,7 +930,7 @@ def main(): timer_response = timer_manager.parse_command(command_text) if timer_response: clean_timer_response = clean_response(timer_response, language="ru") - completed = speak( + completed = output_response( clean_timer_response, check_interrupt=check_wakeword_once ) last_response = clean_timer_response @@ -568,7 +944,7 @@ def main(): alarm_response = alarm_clock.parse_command(command_text) if alarm_response: clean_alarm_response = clean_response(alarm_response, language="ru") - speak(clean_alarm_response) + output_response(clean_alarm_response) last_response = clean_alarm_response pending_time_target = ( "alarm" if alarm_response == ASK_ALARM_TIME_PROMPT else None @@ -577,21 +953,23 @@ def main(): continue # Громкость - if command_text.lower().startswith("громкость"): + if ( + semantic_type == "volume" + and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE + ) or is_volume_command(command_text): try: - vol_str = command_text.lower().replace("громкость", "", 1).strip() - level = parse_volume_text(vol_str) + level = parse_volume_text(command_text) if level is not None: if set_volume(level): - msg = f"Громкость установлена на {level}" + msg = f"Уровень громкости {level}" clean_msg = clean_response(msg, language="ru") - speak(clean_msg) + output_response(clean_msg) last_response = clean_msg else: - speak("Не удалось установить громкость.") + output_response("Не удалось установить громкость.") else: - speak( + output_response( "Я не понял число громкости. Скажите число от одного до десяти." ) @@ -599,54 +977,64 @@ def main(): continue except Exception as e: print(f"❌ Ошибка громкости: {e}") - speak("Не удалось изменить громкость.") + output_response("Не удалось изменить громкость.") skip_wakeword = True continue # Погода - requested_city = None - user_text_lower = command_text.lower() - - for pattern in _CITY_PATTERNS: - match = pattern.search(user_text_lower) - if match: - potential_city = match.group(1).strip() - if ( - potential_city - and len(potential_city) > 1 - and not any( - word in potential_city for word in _CITY_INVALID_WORDS - ) - ): - requested_city = potential_city.title() - break - - has_weather_trigger = any( - trigger in user_text_lower for trigger in _WEATHER_TRIGGERS - ) + requested_city = _extract_requested_city(command_text) + has_weather_trigger = _has_weather_trigger(command_text) 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) + output_response(clean_report, interrupt_guard_seconds=0.0) last_response = clean_report skip_wakeword = True continue + # Время + if _is_time_request(command_text): + now = datetime.now() + time_response = f"Сейчас {now.strftime('%H:%M')}" + clean_time_response = clean_response(time_response, language="ru") + output_response(clean_time_response) + last_response = clean_time_response + skip_wakeword = True + continue + # Музыка music_controller = get_music_controller() music_response = music_controller.parse_command(command_text) if music_response: clean_music_response = clean_response(music_response, language="ru") - speak(clean_music_response) + output_response(clean_music_response) last_response = clean_music_response skip_wakeword = True continue # Перевод translation_request = parse_translation_request(command_text) + if ( + not translation_request + and semantic_type == "translation" + and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE + ): + # Fallback для AI-интента "translation", если нормализатор + # вернул неканоничную фразу без явного префикса "переведи". + translation_request = parse_translation_request( + _strip_wakeword_prefix(user_text) + ) + if not translation_request and semantic_command: + translation_request = parse_translation_request(semantic_command) + if not translation_request: + fallback_payload = (semantic_command or command_text).strip() + if fallback_payload: + translation_request = parse_translation_request( + f"переведи {fallback_payload}" + ) if translation_request: source_lang = translation_request["source_lang"] target_lang = translation_request["target_lang"] @@ -659,25 +1047,32 @@ def main(): if source_lang == "en" else "Скажи фразу на русском." ) - speak(prompt) - 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...") + output_response(prompt) + if text_mode: + text_to_translate = input("⌨️ Текст для перевода> ").strip() + else: try: - cleanup_stt() - get_recognizer().initialize() - except Exception as init_error: - print(f"Ошибка переинициализации STT: {init_error}") - speak("Произошла ошибка при распознавании речи.") - skip_wakeword = False - continue + stop_wakeword_monitoring() + settle_audio_after_tts() + 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}") + output_response("Произошла ошибка при распознавании речи.") + skip_wakeword = False + continue if not text_to_translate: - speak("Я не расслышал текст для перевода.") + output_response("Я не расслышал текст для перевода.") skip_wakeword = False continue @@ -690,9 +1085,9 @@ def main(): last_response = clean_text # Озвучиваем - completed = speak( + completed = output_response( clean_text, - check_interrupt=check_wakeword_once, + check_interrupt=None, language=target_lang, ) stop_wakeword_monitoring() @@ -706,7 +1101,7 @@ def main(): cities_response = cities_game.handle(command_text) if cities_response: clean_cities_response = clean_response(cities_response, language="ru") - speak(clean_cities_response) + output_response(clean_cities_response) last_response = clean_cities_response skip_wakeword = True continue @@ -717,43 +1112,83 @@ def main(): full_response = "" interrupted = False - # Streaming TTS: читаем SSE без блокировок, а озвучиваем в отдельном потоке по предложениям. + # Streaming TTS: читаем SSE без блокировок, а озвучиваем по 2 предложения. tts_queue: "queue.Queue[str | None]" = queue.Queue() + ai_queue: "queue.Queue[str | None]" = queue.Queue() stop_streaming_event = threading.Event() + ai_stream_done = threading.Event() + stream_generator_holder = {"generator": None} - def _split_speakable(text: str) -> tuple[str, str]: + def _find_sentence_boundaries(text: str) -> list[int]: + """Ищет индексы концов предложений в тексте.""" + boundaries = [] + i = 0 + while i < len(text): + ch = text[i] + + # Перенос строки считаем безопасной границей фразы. + if ch == "\n": + boundaries.append(i) + i += 1 + continue + + if ch in ".!?": + # Не режем десятичные числа, например 3.14. + prev_is_digit = i > 0 and text[i - 1].isdigit() + next_is_digit = i + 1 < len(text) and text[i + 1].isdigit() + if ch == "." and prev_is_digit and next_is_digit: + i += 1 + continue + + boundary = i + # Поглощаем подряд идущие знаки конца предложения (?!...) + while boundary + 1 < len(text) and text[boundary + 1] in ".!?": + boundary += 1 + # И закрывающие кавычки/скобки. + while boundary + 1 < len(text) and text[ + boundary + 1 + ] in "\"'”»)]}": + boundary += 1 + + boundaries.append(boundary) + i = boundary + 1 + continue + + i += 1 + + return boundaries + + def _split_speakable(text: str, force: bool = False) -> tuple[str, str]: """ Возвращает (готовое_для_озвучивания, остаток). - Стараемся говорить по предложениям, но не режем слишком мелко. + Основной режим: по 2 предложения на фрагмент. + force=True: дожимаем хвост в конце стрима. """ if not text: return "", "" - # Ждем хотя бы немного текста, чтобы не "пиликать" по 1-2 словам. min_chars = 55 hard_flush_chars = 220 + target_sentences = 2 - if len(text) < min_chars and "\n" not in text: + # Не озвучиваем слишком короткие куски во время потока. + if not force and len(text) < min_chars and "\n" not in text: return "", text - # Находим границу предложения. + boundaries = _find_sentence_boundaries(text) boundary = -1 - for i, ch in enumerate(text): - if ch == "\n": - boundary = i - elif ch in ".!?": - # Не режем 3.14 и похожие случаи. - prev_is_digit = i > 0 and text[i - 1].isdigit() - next_is_digit = i + 1 < len(text) and text[i + 1].isdigit() - if ch == "." and prev_is_digit and next_is_digit: - continue - boundary = i - if boundary == -1: - if len(text) >= hard_flush_chars: - boundary = hard_flush_chars - 1 - else: - return "", text + if len(boundaries) >= target_sentences: + boundary = boundaries[target_sentences - 1] + elif len(boundaries) == 1 and (force or len(text) >= hard_flush_chars): + boundary = boundaries[0] + elif len(boundaries) == 0 and not force and len(text) >= hard_flush_chars: + split_idx = text.rfind(" ", 0, hard_flush_chars) + boundary = split_idx if split_idx > 0 else hard_flush_chars - 1 + elif force: + boundary = len(text) - 1 + else: + return "", text speak_part = text[: boundary + 1].strip() rest = text[boundary + 1 :].lstrip() @@ -772,9 +1207,8 @@ def main(): if not clean_part.strip(): continue - ok = speak( + ok = output_response( clean_part, - check_interrupt=check_wakeword_once, language="ru", ) if not ok: @@ -791,16 +1225,49 @@ def main(): tts_thread = threading.Thread(target=_tts_worker, daemon=True) tts_thread.start() + def _ai_stream_worker(): + generator = None + try: + generator = ask_ai_stream(list(chat_history)) + stream_generator_holder["generator"] = generator + for chunk in generator: + if stop_streaming_event.is_set(): + break + if chunk: + ai_queue.put(chunk) + except Exception as exc: + print(f"\n❌ Ошибка: {exc}") + if not stop_streaming_event.is_set(): + ai_queue.put("Произошла ошибка при получении ответа.") + finally: + if generator is not None: + try: + generator.close() + except Exception: + pass + ai_stream_done.set() + ai_queue.put(None) + + ai_thread = threading.Thread(target=_ai_stream_worker, daemon=True) + ai_thread.start() + print("🤖 AI: ", end="", flush=True) try: - stream_generator = ask_ai_stream(list(chat_history)) buffer = "" - for chunk in stream_generator: + while True: if stop_streaming_event.is_set(): break - if not chunk: + try: + chunk = ai_queue.get(timeout=0.1) + except queue.Empty: + if ai_stream_done.is_set(): + break continue + + if chunk is None: + break + full_response += chunk buffer += chunk print(chunk, end="", flush=True) @@ -814,19 +1281,33 @@ def main(): print(f"\n❌ Ошибка: {e}") tts_queue.put("Произошла ошибка при получении ответа.") finally: + generator = stream_generator_holder.get("generator") + if interrupted: + stop_streaming_event.set() + if generator is not None and interrupted: + try: + generator.close() + except Exception: + pass # Договорим остаток, если не было прерывания. - if not stop_streaming_event.is_set(): - tail = buffer.strip() - if tail: - tts_queue.put(tail) + if not interrupted: + while True: + tail_part, buffer = _split_speakable(buffer, force=True) + if not tail_part: + break + tts_queue.put(tail_part) tts_queue.put(None) - tts_thread.join(timeout=20) + ai_thread.join(timeout=1.0) + # Для длинных ответов не обрываем ожидание по таймауту: + # дожидаемся полного завершения TTS worker. + tts_thread.join() print() - # Сохраняем ответ - chat_history.append({"role": "assistant", "content": full_response}) - last_response = clean_response(full_response, language="ru") + # Сохраняем только завершенный ответ, чтобы не засорять контекст обрезанным хвостом. + if full_response and not interrupted: + chat_history.append({"role": "assistant", "content": full_response}) + last_response = clean_response(full_response, language="ru") stop_wakeword_monitoring() skip_wakeword = True @@ -842,8 +1323,14 @@ def main(): signal_handler(None, None) except Exception as e: print(f"❌ Ошибка: {e}") - speak("Произошла ошибка. Попробуйте ещё раз.") + output_response("Произошла ошибка. Попробуйте ещё раз.") skip_wakeword = False + finally: + if wakeword_music_ducked: + try: + music_controller.restore_after_wakeword() + except Exception: + pass if __name__ == "__main__": diff --git a/data/alarms.json b/data/alarms.json index 2a72af5..39a675f 100644 --- a/data/alarms.json +++ b/data/alarms.json @@ -39,5 +39,53 @@ "days": [ 1 ] + }, + { + "hour": 8, + "minute": 0, + "active": false, + "days": [ + 0, + 1, + 2, + 3, + 4 + ], + "last_triggered": null + }, + { + "hour": 7, + "minute": 0, + "active": true, + "days": [ + 0, + 1, + 2, + 3, + 4 + ], + "last_triggered": "2026-04-07T07:00:00.445214" + }, + { + "hour": 7, + "minute": 0, + "active": false, + "days": [ + 5 + ] + }, + { + "hour": 9, + "minute": 30, + "active": false, + "days": null, + "last_triggered": "2026-04-04T09:30:00.423048" + }, + { + "hour": 17, + "minute": 30, + "active": false, + "days": null, + "last_triggered": "2026-04-04T17:30:00.113480" } ] \ No newline at end of file diff --git a/scripts/qwen-check.sh b/scripts/qwen-check.sh index 7a7c762..d02abff 100755 --- a/scripts/qwen-check.sh +++ b/scripts/qwen-check.sh @@ -4,8 +4,13 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +PYTHON_BIN="python3" +if [ -x "$ROOT/.venv/bin/python" ]; then + PYTHON_BIN="$ROOT/.venv/bin/python" +fi + echo "[qwen-check] Python syntax compile check" -python -m compileall app run.py >/dev/null +"$PYTHON_BIN" -m compileall app run.py >/dev/null echo "[qwen-check] Optional ruff check" if command -v ruff >/dev/null 2>&1; then