diff --git a/.env.example b/.env.example index 52356ab..4bef9cc 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,6 @@ TTS_EN_SPEAKER=en_0 WEATHER_LAT=63.56 WEATHER_LON=53.69 WEATHER_CITY=Ухта +SPOTIFY_CLIENT_ID=your_spotify_client_id +SPOTIFY_CLIENT_SECRET=your_spotify_client_secret +SPOTIFY_REDIRECT_URI=http://localhost:8888/callback diff --git a/app/audio/stt.py b/app/audio/stt.py index c7f41a9..2517895 100644 --- a/app/audio/stt.py +++ b/app/audio/stt.py @@ -186,7 +186,7 @@ class SpeechRecognizer: dg_connection.send(data) chunks_sent += 1 if chunks_sent % 50 == 0: - print(f".", end="", flush=True) + print(".", end="", flush=True) await asyncio.sleep(0.005) except Exception as e: diff --git a/app/audio/tts.py b/app/audio/tts.py index b149bef..dfc42ae 100644 --- a/app/audio/tts.py +++ b/app/audio/tts.py @@ -8,14 +8,16 @@ Supports interruption via wake word detection using threading. # Использует нейросеть Silero TTS для качественной русской речи. # Также поддерживает прерывание речи, если пользователь скажет "Alexandr". -import torch -import sounddevice as sd -import numpy as np +import re import threading import time import warnings -import re -from ..core.config import TTS_SPEAKER, TTS_EN_SPEAKER, TTS_SAMPLE_RATE + +import numpy as np +import sounddevice as sd +import torch + +from ..core.config import TTS_EN_SPEAKER, TTS_SAMPLE_RATE, TTS_SPEAKER # Подавляем предупреждения Silero о длинном тексте (мы сами его режем) warnings.filterwarnings("ignore", message="Text string is longer than 1000 symbols") @@ -46,12 +48,21 @@ class TextToSpeech: if self.model_en: return self.model_en print("📦 Загрузка модели Silero TTS (en)...") - model, _ = torch.hub.load( - repo_or_dir="snakers4/silero-models", - model="silero_tts", - language="en", - speaker="v3_en", - ) + 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.to(device) self.model_en = model return model @@ -71,8 +82,18 @@ class TextToSpeech: return model def initialize(self): - """Предварительная инициализация (прогрев) русской модели.""" + """Предварительная инициализация (прогрев) русской и английской моделей.""" self._load_model("ru") + self._load_model("en") + + def _preprocess_text(self, text: str) -> str: + """ + Предварительная обработка текста перед озвучкой. + Заменяет тире между цифрами на слово "тире" для корректного чтения. + """ + # Замена 18-43 на "18 тире 43" + text = re.sub(r"(\d+)-(\d+)", r"\1 тире \2", text) + return text def _split_text(self, text: str, max_length: int = 900) -> list[str]: """ @@ -263,6 +284,7 @@ class TextToSpeech: return True if language == "ru": + text = self._preprocess_text(text) segments = self._split_mixed_language(text) if any(lang == "en" for _, lang in segments): return self._speak_mixed(segments, check_interrupt=check_interrupt) diff --git a/app/audio/wakeword.py b/app/audio/wakeword.py index c885046..3d4b14c 100644 --- a/app/audio/wakeword.py +++ b/app/audio/wakeword.py @@ -44,7 +44,7 @@ class WakeWordDetector: if self.audio_stream: try: self.audio_stream.close() - except: + except Exception: pass # Открываем поток с параметрами, которые требует Porcupine @@ -63,7 +63,7 @@ class WakeWordDetector: try: self.audio_stream.stop_stream() self.audio_stream.close() - except: + except Exception: pass self._stream_closed = True diff --git a/app/core/ai.py b/app/core/ai.py index 096a545..8533b56 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -4,6 +4,7 @@ # Обрабатывает запросы пользователя и переводы. import requests +import re from .config import PERPLEXITY_API_KEY, PERPLEXITY_MODEL, PERPLEXITY_API_URL @@ -21,7 +22,9 @@ SYSTEM_PROMPT = """Ты — Александр, умный голосовой а # Требует возвращать ТОЛЬКО перевод, без лишних слов ("Конечно, вот перевод..."). TRANSLATION_SYSTEM_PROMPT = """You are a translation engine. Translate from {source} to {target}. -Return only the translated text, without quotes, comments, or explanations. +Return 2-3 short translation variants only. +No explanations, no quotes, no comments. +Separate variants with " / " (space slash space). Keep the translation максимально кратким и естественным, без лишних слов.""" @@ -178,8 +181,22 @@ def translate_text(text: str, source_lang: str, target_lang: str) -> str: response = _send_request( messages, - max_tokens=400, + max_tokens=160, temperature=0.2, # Низкая температура для точности перевода error_text="Произошла ошибка при переводе. Попробуйте ещё раз.", ) - return response.strip() + cleaned = response.strip() + 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-•") + if item: + parts.append(item) + if not parts: + return cleaned + + parts = parts[:3] + return " / ".join(parts) diff --git a/app/core/cleaner.py b/app/core/cleaner.py index 7275388..1e65ec9 100644 --- a/app/core/cleaner.py +++ b/app/core/cleaner.py @@ -91,6 +91,9 @@ MONTHS_GENITIVE = [ "декабря", ] +# Леммы единиц времени (для корректного падежа числительных) +TIME_UNIT_LEMMAS = {"час", "минута", "секунда"} + def get_case_from_preposition(prep_token): """Определяет падеж по предлогу.""" @@ -127,7 +130,6 @@ def numbers_to_words(text: str) -> str: # 1. Обработка годов: "в 1999 году", "2024 год" def replace_year_match(match): - full_str = match.group(0) prep = match.group(1) # Предлог (в, с, к...) year_str = match.group(2) # Само число year_word = match.group(3) # Слово "год", "году" и т.д. @@ -207,9 +209,10 @@ def numbers_to_words(text: str) -> str: case = "nominative" gender = "m" + prep_clean = prep.strip().lower() if prep else None - if prep: - morph_case = get_case_from_preposition(prep.strip()) + if prep_clean: + morph_case = get_case_from_preposition(prep_clean) if morph_case: case = PYMORPHY_TO_NUM2WORDS.get(morph_case, "nominative") @@ -221,6 +224,16 @@ def numbers_to_words(text: str) -> str: morph_gender = parsed.tag.gender gender = PYMORPHY_TO_GENDER.get(morph_gender, "m") + # Спец-случай: "на 1 час" -> "на один час" (не "одного") + # Для неодушевленных муж./ср. рода в винительном падеже + # числительные должны совпадать с именительным. + if ( + prep_clean == "на" + and parsed.normal_form in TIME_UNIT_LEMMAS + and parsed.tag.gender in ("masc", "neut") + ): + case = "nominative" + words = convert_number( num_str, context_type="cardinal", case=case, gender=gender ) diff --git a/app/core/commands.py b/app/core/commands.py new file mode 100644 index 0000000..7359223 --- /dev/null +++ b/app/core/commands.py @@ -0,0 +1,66 @@ +""" +Command parsing helpers. +""" + +import re + +_STOP_WORDS_STRICT = { + "стоп", + "хватит", + "перестань", + "замолчи", + "прекрати", + "тихо", + "stop", +} + +_STOP_PATTERNS_LENIENT = [ + r"\bстоп\w*\b", + r"\bstop\b", + r"\bхватит\b", + r"\bперестан\w*\b", + r"\bпрекрат\w*\b", + r"\bзамолч\w*\b", + r"\bтише\b", + r"\bтихо\b", + r"\bвыключ\w*\b", + r"\bотключ\w*\b", + r"\bостанов\w*\b", + r"\bотмен\w*\b", + r"\bпауза\b", + r"\bдостаточно\b", +] +_STOP_PATTERNS_LENIENT_COMPILED = [re.compile(p) for p in _STOP_PATTERNS_LENIENT] + + +def _normalize_text(text: str) -> str: + text = text.lower().replace("ё", "е") + text = re.sub(r"[^\w\s]+", " ", text, flags=re.UNICODE) + text = re.sub(r"\s+", " ", text, flags=re.UNICODE).strip() + return text + + +def is_stop_command(text: str, mode: str = "strict") -> bool: + """ + Detect stop commands in text. + + mode: + - "strict": only exact stop words. + - "lenient": broader patterns for noisy recognition. + """ + if not text: + return False + + normalized = _normalize_text(text) + if not normalized: + return False + + if mode == "strict": + words = normalized.split() + return any(word in _STOP_WORDS_STRICT for word in words) + + for pattern in _STOP_PATTERNS_LENIENT_COMPILED: + if pattern.search(normalized): + return True + + return False diff --git a/app/core/config.py b/app/core/config.py index 15932af..e520126 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -7,6 +7,7 @@ Loads environment variables from .env file. # Он загружает настройки из файла .env (переменные окружения) и определяет константы. import os +import time from pathlib import Path from dotenv import load_dotenv @@ -40,7 +41,6 @@ CHANNELS = 1 # --- Настройка времени --- # Устанавливаем часовой пояс на Москву, чтобы будильник работал корректно -import time os.environ["TZ"] = "Europe/Moscow" time.tzset() diff --git a/app/features/alarm.py b/app/features/alarm.py index 33131eb..5fca5a3 100644 --- a/app/features/alarm.py +++ b/app/features/alarm.py @@ -7,9 +7,9 @@ import json import subprocess import re from datetime import datetime -from pathlib import Path from ..core.config import BASE_DIR from ..audio.stt import listen +from ..core.commands import is_stop_command # Файл базы данных будильников ALARM_FILE = BASE_DIR / "data" / "alarms.json" @@ -22,6 +22,73 @@ class AlarmClock: self.alarms = [] self.load_alarms() + def _normalize_days(self, days): + if not days: + return None + unique = sorted({int(day) for day in days}) + return unique + + def _days_key(self, days): + normalized = self._normalize_days(days) + return tuple(normalized) if normalized else None + + def _format_days_phrase(self, days): + normalized = self._normalize_days(days) + if not normalized: + return "" + + day_set = set(normalized) + if day_set == {0, 1, 2, 3, 4}: + return "по будням" + if day_set == {5, 6}: + return "по выходным" + if len(day_set) == 7: + return "каждый день" + + names = { + 0: "понедельникам", + 1: "вторникам", + 2: "средам", + 3: "четвергам", + 4: "пятницам", + 5: "субботам", + 6: "воскресеньям", + } + ordered = [names[d] for d in normalized if d in names] + if not ordered: + return "" + if len(ordered) == 1: + return f"по {ordered[0]}" + return "по " + ", ".join(ordered[:-1]) + " и " + ordered[-1] + + def _extract_alarm_days(self, text: str): + text = text.lower().replace("ё", "е") + days = set() + + if re.search(r"\b(каждый день|ежедневно)\b", text): + return [0, 1, 2, 3, 4, 5, 6] + + if re.search(r"\b(по будн|в будн|будние)\b", text): + days.update([0, 1, 2, 3, 4]) + + if re.search(r"\b(по выходн|в выходн|выходные)\b", text): + days.update([5, 6]) + + day_patterns = { + 0: r"\bпонедельн\w*\b", + 1: r"\bвторник\w*\b", + 2: r"\bсред\w*\b", + 3: r"\bчетверг\w*\b", + 4: r"\bпятниц\w*\b", + 5: r"\bсуббот\w*\b", + 6: r"\bвоскресен\w*\b", + } + for day_idx, pattern in day_patterns.items(): + if re.search(pattern, text): + days.add(day_idx) + + return self._normalize_days(days) + def load_alarms(self): """Загрузка списка будильников из JSON файла.""" if ALARM_FILE.exists(): @@ -42,15 +109,30 @@ class AlarmClock: def add_alarm(self, hour: int, minute: int): """Добавление нового будильника (или обновление существующего).""" + return self.add_alarm_with_days(hour, minute, days=None) + + def add_alarm_with_days(self, hour: int, minute: int, days=None): + """Добавление нового будильника (или обновление существующего) с днями недели.""" + days_key = self._days_key(days) for alarm in self.alarms: - if alarm["hour"] == hour and alarm["minute"] == minute: + if ( + alarm.get("hour") == hour + and alarm.get("minute") == minute + and self._days_key(alarm.get("days")) == days_key + ): alarm["active"] = True + alarm["days"] = days_key + alarm["last_triggered"] = None self.save_alarms() return - self.alarms.append({"hour": hour, "minute": minute, "active": True}) + self.alarms.append( + {"hour": hour, "minute": minute, "active": True, "days": days_key} + ) self.save_alarms() - print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}") + days_phrase = self._format_days_phrase(days_key) + suffix = f" {days_phrase}" if days_phrase else "" + print(f"⏰ Будильник установлен на {hour:02d}:{minute:02d}{suffix}") def cancel_all_alarms(self): """Выключение (деактивация) всех будильников.""" @@ -59,6 +141,27 @@ class AlarmClock: self.save_alarms() print("🔕 Все будильники отменены.") + def describe_alarms(self) -> str: + """Возвращает текстовое описание активных будильников.""" + active = [ + alarm + for alarm in self.alarms + if alarm.get("active") and "hour" in alarm and "minute" in alarm + ] + if not active: + return "Активных будильников нет." + + active.sort(key=lambda a: (a["hour"], a["minute"])) + items = [] + for alarm in active: + time_str = f"{alarm['hour']:02d}:{alarm['minute']:02d}" + days_phrase = self._format_days_phrase(alarm.get("days")) + if days_phrase: + items.append(f"{days_phrase} в {time_str}") + else: + items.append(time_str) + return "Активные будильники: " + ", ".join(items) + "." + def check_alarms(self): """ Проверка: не пора ли звенеть? @@ -71,12 +174,30 @@ class AlarmClock: for alarm in self.alarms: if alarm["active"]: if alarm["hour"] == now.hour and alarm["minute"] == now.minute: + days = self._normalize_days(alarm.get("days")) + if days and now.weekday() not in days: + continue + + last_triggered = alarm.get("last_triggered") + if last_triggered: + try: + last_dt = datetime.fromisoformat(last_triggered) + if ( + last_dt.date() == now.date() + and last_dt.hour == now.hour + and last_dt.minute == now.minute + ): + continue + except Exception: + pass + print( f"⏰ ВРЕМЯ БУДИЛЬНИКА: {alarm['hour']:02d}:{alarm['minute']:02d}" ) - alarm["active"] = ( - False # Одноразовый будильник, выключаем после срабатывания - ) + if not days: + # Одноразовый будильник, выключаем после срабатывания + alarm["active"] = False + alarm["last_triggered"] = now.isoformat() triggered = True self.trigger_alarm() # Запуск звука и ожидание стоп-слова break # Звоним только один за раз @@ -106,21 +227,11 @@ class AlarmClock: return try: - stop_words = [ - "стоп", - "хватит", - "тихо", - "замолчи", - "отмена", - "александр стоп", - ] - # Цикл ожидания стоп-команды while True: text = listen(timeout_seconds=3.0, detection_timeout=3.0) if text: - text_lower = text.lower() - if any(word in text_lower for word in stop_words): + if is_stop_command(text, mode="lenient"): print(f"🛑 Будильник остановлен по команде: '{text}'") break @@ -144,17 +255,26 @@ class AlarmClock: if "будильник" not in text and "разбуди" not in text: return None + if "будильник" in text and re.search( + r"(какие|какой|список|активн|покажи|сколько|есть ли)", text + ): + return self.describe_alarms() + if "отмени" in text: self.cancel_all_alarms() return "Хорошо, я отменил все будильники." + days = self._extract_alarm_days(text) + # Поиск формата "7:30", "7.30" match = re.search(r"\b(\d{1,2})[:.-](\d{2})\b", text) if match: h, m = int(match.group(1)), int(match.group(2)) if 0 <= h <= 23 and 0 <= m <= 59: - self.add_alarm(h, m) - return f"Я установил будильник на {h} часов {m} минут." + 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 часов 15 минут" match_time = re.search( @@ -174,8 +294,10 @@ class AlarmClock: h = 0 if 0 <= h <= 23 and 0 <= m <= 59: - self.add_alarm(h, m) - return f"Хорошо, разбужу вас в {h}:{m:02d}." + 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}." return "Я не понял время для будильника. Пожалуйста, скажите точное время, например 'семь тридцать'." diff --git a/app/features/music.py b/app/features/music.py new file mode 100644 index 0000000..5f7afc2 --- /dev/null +++ b/app/features/music.py @@ -0,0 +1,324 @@ +""" +Spotify Music Controller +Модуль для управления воспроизведением музыки через Spotify API. + +Поддерживаемые команды: +- "включи музыку" / "play music" - воспроизведение топ-треков или по запросу +- "пауза" / "стоп музыка" - пауза +- "продолжи" / "дальше" - возобновление +- "следующий трек" / "next" - следующий трек +- "предыдущий трек" / "previous" - предыдущий трек +- "что играет" / "какая песня" - информация о текущем треке +""" + +import os +import re +from typing import Optional + +try: + import spotipy + from spotipy.oauth2 import SpotifyOAuth +except ImportError: + spotipy = None + SpotifyOAuth = None + +# Singleton instance +_music_controller = None + + +class SpotifyMusicController: + """Контроллер для управления Spotify воспроизведением.""" + + # Scopes для Spotify API + SCOPES = [ + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "user-top-read", + "streaming", + ] + + def __init__(self): + """Инициализация контроллера Spotify.""" + self.sp = None + self._initialized = False + self._init_error = None + + def initialize(self) -> bool: + """ + Инициализация подключения к Spotify. + Возвращает True при успехе, False при ошибке. + """ + if self._initialized: + return True + + if spotipy is None: + self._init_error = "Библиотека spotipy не установлена. Установите: pip install spotipy" + print(f"⚠️ Spotify: {self._init_error}") + return False + + # Проверяем наличие ключей + client_id = os.getenv("SPOTIFY_CLIENT_ID") + client_secret = os.getenv("SPOTIFY_CLIENT_SECRET") + redirect_uri = os.getenv("SPOTIFY_REDIRECT_URI", "http://localhost:8888/callback") + + if not client_id or not client_secret: + self._init_error = "Не заданы SPOTIFY_CLIENT_ID и SPOTIFY_CLIENT_SECRET в .env" + print(f"⚠️ Spotify: {self._init_error}") + return False + + try: + auth_manager = SpotifyOAuth( + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + scope=" ".join(self.SCOPES), + cache_path=".spotify_cache", + ) + self.sp = spotipy.Spotify(auth_manager=auth_manager) + # Проверяем подключение + self.sp.current_user() + self._initialized = True + print("✅ Spotify: подключено") + return True + except Exception as e: + self._init_error = str(e) + print(f"❌ Spotify: ошибка инициализации - {e}") + return False + + def _ensure_initialized(self) -> bool: + """Проверяет и инициализирует при необходимости.""" + if not self._initialized: + return self.initialize() + return True + + def _get_active_device(self) -> Optional[str]: + """Получить ID активного устройства воспроизведения.""" + try: + devices = self.sp.devices() + if devices and devices.get("devices"): + # Ищем активное устройство или берем первое + for device in devices["devices"]: + if device.get("is_active"): + return device["id"] + # Если нет активного, берем первое + return devices["devices"][0]["id"] + except Exception: + pass + return None + + def play_music(self, query: Optional[str] = None) -> str: + """ + Включить музыку. + + Args: + query: Поисковый запрос (название песни/артиста). + Если None, включает топ-треки пользователя. + + Returns: + Сообщение для озвучки. + """ + if not self._ensure_initialized(): + return f"Не удалось подключиться к Spotify. {self._init_error or ''}" + + try: + device_id = self._get_active_device() + + if query: + # Поиск по запросу + results = self.sp.search(q=query, type="track", limit=1) + tracks = results.get("tracks", {}).get("items", []) + + if not tracks: + return f"Не нашёл песню '{query}'" + + track = tracks[0] + track_name = track["name"] + artist_name = track["artists"][0]["name"] + track_uri = track["uri"] + + self.sp.start_playback(device_id=device_id, uris=[track_uri]) + return f"Включаю {track_name} от {artist_name}" + else: + # Включаем топ-треки пользователя + top_tracks = self.sp.current_user_top_tracks(limit=20, time_range="medium_term") + if top_tracks and top_tracks.get("items"): + uris = [track["uri"] for track in top_tracks["items"]] + self.sp.start_playback(device_id=device_id, uris=uris) + first_track = top_tracks["items"][0] + return f"Включаю вашу музыку. Сейчас играет {first_track['name']}" + else: + # Если нет топ-треков, ставим что-нибудь популярное + self.sp.start_playback(device_id=device_id) + return "Включаю музыку" + + except spotipy.SpotifyException as e: + if "NO_ACTIVE_DEVICE" in str(e) or "Player command failed" in str(e): + return "Нет активного устройства Spotify. Откройте Spotify на телефоне или компьютере." + elif "PREMIUM_REQUIRED" in str(e): + return "Для управления воспроизведением нужен Spotify Premium." + return f"Ошибка Spotify: {e.reason if hasattr(e, 'reason') else str(e)}" + except Exception as e: + return f"Ошибка воспроизведения: {e}" + + def pause_music(self) -> str: + """Поставить на паузу.""" + if not self._ensure_initialized(): + return "Spotify не подключён" + + try: + self.sp.pause_playback() + return "Музыка на паузе" + except spotipy.SpotifyException as e: + if "NO_ACTIVE_DEVICE" in str(e): + return "Нет активного устройства Spotify" + return f"Не удалось поставить на паузу: {e}" + except Exception as e: + return f"Ошибка: {e}" + + def resume_music(self) -> str: + """Продолжить воспроизведение.""" + if not self._ensure_initialized(): + return "Spotify не подключён" + + try: + self.sp.start_playback() + return "Продолжаю воспроизведение" + except spotipy.SpotifyException as e: + if "NO_ACTIVE_DEVICE" in str(e): + return "Нет активного устройства Spotify" + return f"Ошибка: {e}" + except Exception as e: + return f"Ошибка: {e}" + + def next_track(self) -> str: + """Следующий трек.""" + if not self._ensure_initialized(): + return "Spotify не подключён" + + try: + self.sp.next_track() + # Небольшая задержка для обновления состояния + import time + time.sleep(0.5) + return self.get_current_track() or "Переключаю на следующий трек" + except Exception as e: + return f"Не удалось переключить трек: {e}" + + def previous_track(self) -> str: + """Предыдущий трек.""" + if not self._ensure_initialized(): + return "Spotify не подключён" + + try: + self.sp.previous_track() + import time + time.sleep(0.5) + return self.get_current_track() or "Переключаю на предыдущий трек" + except Exception as e: + return f"Не удалось переключить трек: {e}" + + def get_current_track(self) -> Optional[str]: + """Получить информацию о текущем треке.""" + if not self._ensure_initialized(): + return "Spotify не подключён" + + try: + current = self.sp.current_playback() + if current and current.get("item"): + track = current["item"] + name = track["name"] + artists = ", ".join(a["name"] for a in track["artists"]) + is_playing = current.get("is_playing", False) + status = "Сейчас играет" if is_playing else "На паузе" + return f"{status}: {name} от {artists}" + return "Сейчас ничего не играет" + except Exception as e: + return f"Не удалось получить информацию: {e}" + + def parse_command(self, text: str) -> Optional[str]: + """ + Распознать музыкальную команду и выполнить её. + + Args: + text: Текст команды от пользователя. + + Returns: + Ответ для озвучки или None, если это не музыкальная команда. + """ + text_lower = text.lower().strip() + + # Команды паузы + pause_patterns = [ + r"^(поставь на паузу|пауза|стоп музык|останови музык|выключи музык)", + r"(pause|stop music)", + ] + for pattern in pause_patterns: + if re.search(pattern, text_lower): + return self.pause_music() + + # Команды продолжения + resume_patterns = [ + r"^(продолжи|продолжай|возобнови|сними с паузы|дальше|играй дальше)", + r"(resume|continue|play)", + ] + for pattern in resume_patterns: + if re.search(pattern, text_lower): + return self.resume_music() + + # Следующий трек + next_patterns = [ + r"(следующ|дальше|скип|пропусти|next|skip)", + ] + for pattern in next_patterns: + if re.search(pattern, text_lower) and "трек" in text_lower or "песн" in text_lower or "skip" in text_lower or "next" in text_lower: + return self.next_track() + + # Предыдущий трек + prev_patterns = [ + r"(предыдущ|назад|верни|previous|back)", + ] + for pattern in prev_patterns: + if re.search(pattern, text_lower) and ("трек" in text_lower or "песн" in text_lower or "previous" in text_lower or "back" in text_lower): + return self.previous_track() + + # Что играет + current_patterns = [ + r"(что (сейчас )?играет|как(ая|ой) (песня|трек)|что за (песня|трек|музыка))", + r"(what.*(play|song)|current track)", + ] + for pattern in current_patterns: + if re.search(pattern, text_lower): + return self.get_current_track() + + # Включить музыку (с возможным запросом) + play_patterns = [ + (r"^включи\s+музыку$", None), # Просто "включи музыку" + (r"^включи\s+(.+)$", 1), # "включи [что-то]" + (r"^поставь\s+(.+)$", 1), # "поставь [что-то]" + (r"^играй\s+(.+)$", 1), # "играй [что-то]" + (r"^play\s+(.+)$", 1), # "play [something]" + (r"^(play music|включи музыку|поставь музыку)$", None), + ] + + for pattern, group in play_patterns: + match = re.search(pattern, text_lower) + if match: + query = None + if group: + query = match.group(group).strip() + # Убираем слова "музыку", "песню", "трек" из запроса + query = re.sub(r"(музыку|песню|трек|песня|song|track)\s*", "", query).strip() + if not query: + query = None + return self.play_music(query) + + return None + + +def get_music_controller() -> SpotifyMusicController: + """Получить singleton экземпляр контроллера музыки.""" + global _music_controller + if _music_controller is None: + _music_controller = SpotifyMusicController() + return _music_controller diff --git a/app/features/timer.py b/app/features/timer.py index 1b86da9..f5f2fd1 100644 --- a/app/features/timer.py +++ b/app/features/timer.py @@ -5,19 +5,307 @@ import subprocess import re +import json from datetime import datetime, timedelta -from pathlib import Path from ..core.config import BASE_DIR from ..audio.stt import listen +from ..core.commands import is_stop_command + +# Morphological analysis for better recognition of number words. +try: + import pymorphy3 + + _MORPH = pymorphy3.MorphAnalyzer() +except Exception: + _MORPH = None # Звуковой файл сигнала (используем тот же, что и для будильника) ALARM_SOUND = BASE_DIR / "assets" / "sounds" / "Apex-1.mp3" +TIMER_FILE = BASE_DIR / "data" / "timers.json" + +# --- Number words parsing helpers (ru) --- +_NUMBER_UNITS = { + "ноль": 0, + "один": 1, + "два": 2, + "три": 3, + "четыре": 4, + "пять": 5, + "шесть": 6, + "семь": 7, + "восемь": 8, + "девять": 9, +} +_NUMBER_TEENS = { + "десять": 10, + "одиннадцать": 11, + "двенадцать": 12, + "тринадцать": 13, + "четырнадцать": 14, + "пятнадцать": 15, + "шестнадцать": 16, + "семнадцать": 17, + "восемнадцать": 18, + "девятнадцать": 19, +} +_NUMBER_TENS = { + "двадцать": 20, + "тридцать": 30, + "сорок": 40, + "пятьдесят": 50, + "шестьдесят": 60, + "семьдесят": 70, + "восемьдесят": 80, + "девяносто": 90, +} +_NUMBER_HUNDREDS = { + "сто": 100, + "двести": 200, + "триста": 300, + "четыреста": 400, + "пятьсот": 500, + "шестьсот": 600, + "семьсот": 700, + "восемьсот": 800, + "девятьсот": 900, +} +_NUMBER_SPECIAL = { + "пол": 0.5, + "полтора": 1.5, + "полторы": 1.5, +} +_NUMBER_LEMMAS = ( + set(_NUMBER_UNITS) + | set(_NUMBER_TEENS) + | set(_NUMBER_TENS) + | set(_NUMBER_HUNDREDS) + | set(_NUMBER_SPECIAL) +) +_IGNORED_LEMMAS = { + "на", + "в", + "во", + "за", + "через", + "по", + "к", + "ко", + "с", + "со", + "и", +} +_UNIT_LEMMAS = { + "час": "hours", + "минута": "minutes", + "секунда": "seconds", + "мин": "minutes", + "сек": "seconds", +} +_UNIT_FORMS = { + "hours": ("час", "часа", "часов"), + "minutes": ("минуту", "минуты", "минут"), + "seconds": ("секунду", "секунды", "секунд"), +} + +# Optional ordinal formatting for list numbering. +try: + from num2words import num2words +except Exception: + num2words = None + + +def _lemmatize(token: str) -> str: + if _MORPH is None: + return token + return _MORPH.parse(token)[0].normal_form + + +def _tokenize_with_lemmas(text: str): + tokens = [] + for match in re.finditer(r"[a-zA-Zа-яА-ЯёЁ]+|\d+", text.lower()): + raw = match.group(0) + lemma = _lemmatize(raw) if not raw.isdigit() else raw + tokens.append({"raw": raw, "lemma": lemma}) + return tokens + + +def _parse_number_lemmas(lemmas): + if not lemmas: + return None + + if len(lemmas) == 1 and lemmas[0] in _NUMBER_SPECIAL: + return _NUMBER_SPECIAL[lemmas[0]] + + total = 0 + idx = 0 + + if lemmas[idx] in _NUMBER_HUNDREDS: + total += _NUMBER_HUNDREDS[lemmas[idx]] + idx += 1 + + if idx < len(lemmas) and lemmas[idx] in _NUMBER_TEENS: + total += _NUMBER_TEENS[lemmas[idx]] + idx += 1 + else: + if idx < len(lemmas) and lemmas[idx] in _NUMBER_TENS: + total += _NUMBER_TENS[lemmas[idx]] + idx += 1 + if idx < len(lemmas) and lemmas[idx] in _NUMBER_UNITS: + total += _NUMBER_UNITS[lemmas[idx]] + idx += 1 + + if total == 0 and lemmas[0] in _NUMBER_UNITS: + return _NUMBER_UNITS[lemmas[0]] + + return total if total > 0 else None + + +def _normalize_timer_text(text: str) -> str: + # Split "полчаса/полминуты/полсекунды" into "пол часа" for easier parsing. + return re.sub( + r"(?i)\bпол(?=(?:час|часа|минут|минуты|минуту|секунд|секунды|секунду|мин|сек)\b)", + "пол ", + text, + ) + + +def _find_word_number_before_unit(tokens, unit_index): + collected = [] + idx = unit_index - 1 + while idx >= 0 and len(collected) < 4: + lemma = tokens[idx]["lemma"] + if lemma in _IGNORED_LEMMAS: + idx -= 1 + continue + if lemma in _NUMBER_LEMMAS: + collected.insert(0, lemma) + idx -= 1 + continue + break + return _parse_number_lemmas(collected) + + +def _extract_word_time_values(text: str): + tokens = _tokenize_with_lemmas(text) + values = {"hours": None, "minutes": None, "seconds": None} + + for idx, token in enumerate(tokens): + lemma = token["lemma"] + key = _UNIT_LEMMAS.get(lemma) + if not key: + continue + value = _find_word_number_before_unit(tokens, idx) + if value is not None: + values[key] = value + return values + + +def _format_unit(value: int, unit_key: str) -> str: + if unit_key not in _UNIT_FORMS: + return f"{value}" + + one, few, many = _UNIT_FORMS[unit_key] + n = abs(int(value)) + if n % 100 in (11, 12, 13, 14): + word = many + elif n % 10 == 1: + word = one + elif n % 10 in (2, 3, 4): + word = few + else: + word = many + return f"{value} {word}" + + +def _format_duration(total_seconds: float) -> str: + total_seconds = int(round(total_seconds)) + if total_seconds < 0: + total_seconds = 0 + + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + parts = [] + if hours: + parts.append(_format_unit(hours, "hours")) + if minutes: + parts.append(_format_unit(minutes, "minutes")) + if seconds or not parts: + parts.append(_format_unit(seconds, "seconds")) + return " ".join(parts) + + +def _format_ordinal_index(index: int) -> str: + if num2words is None: + return f"{index}-й" + try: + return num2words(index, lang="ru", to="ordinal", case="nominative", gender="m") + except Exception: + return f"{index}-й" class TimerManager: def __init__(self): # Список активных таймеров: {"end_time": datetime, "label": str} self.timers = [] + self.load_timers() + + def load_timers(self): + """Загрузка списка таймеров из JSON файла.""" + if TIMER_FILE.exists(): + try: + with open(TIMER_FILE, "r", encoding="utf-8") as f: + raw = json.load(f) + except Exception as e: + print(f"❌ Ошибка загрузки таймеров: {e}") + return + + timers = [] + for item in raw: + try: + end_time = datetime.fromisoformat(item["end_time"]) + except Exception: + continue + label = item.get("label", "") + timers.append({"end_time": end_time, "label": label}) + + self.timers = sorted(timers, key=lambda x: x["end_time"]) + + def save_timers(self): + """Сохранение списка таймеров в JSON файл.""" + payload = [ + {"end_time": t["end_time"].isoformat(), "label": t.get("label", "")} + for t in self.timers + ] + try: + with open(TIMER_FILE, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=4) + except Exception as e: + print(f"❌ Ошибка сохранения таймеров: {e}") + + def describe_timers(self) -> str: + """Возвращает текстовое описание активных таймеров.""" + if not self.timers: + return "Активных таймеров нет." + + now = datetime.now() + items = [] + for idx, timer in enumerate(self.timers, start=1): + remaining = (timer["end_time"] - now).total_seconds() + label = timer.get("label", "").strip() + ordinal = _format_ordinal_index(idx) + if remaining <= 0: + status = "сработает сейчас" + else: + status = f"осталось {_format_duration(remaining)}" + + if label: + items.append(f"{ordinal}) {label} — {status}") + else: + items.append(f"{ordinal}) {status}") + + return "Активные таймеры: " + "; ".join(items) + "." def add_timer(self, seconds: int, label: str): """Добавление нового таймера.""" @@ -25,12 +313,14 @@ class TimerManager: self.timers.append({"end_time": end_time, "label": label}) # Сортируем, чтобы ближайший был первым self.timers.sort(key=lambda x: x["end_time"]) + self.save_timers() print(f"⏳ Таймер установлен на {label} (до {end_time.strftime('%H:%M:%S')})") def cancel_all_timers(self): """Отмена всех таймеров.""" count = len(self.timers) self.timers = [] + self.save_timers() print(f"🔕 Все таймеры ({count}) отменены.") def check_timers(self): @@ -52,6 +342,7 @@ class TimerManager: # Удаляем его из списка label = first_timer["label"] self.timers.pop(0) + self.save_timers() print(f"⌛ ТАЙМЕР ИСТЕК: {label}") self.trigger_timer(label) @@ -78,22 +369,11 @@ class TimerManager: return try: - stop_words = [ - "стоп", - "хватит", - "тихо", - "замолчи", - "отмена", - "александр стоп", - "спасибо", - ] - # Цикл ожидания стоп-команды while True: text = listen(timeout_seconds=3.0, detection_timeout=3.0) if text: - text_lower = text.lower() - if any(word in text_lower for word in stop_words): + if is_stop_command(text, mode="lenient"): print(f"🛑 Таймер остановлен по команде: '{text}'") break @@ -113,12 +393,17 @@ class TimerManager: Парсинг команды установки таймера. Примеры: "таймер на 5 минут", "засеки 10 секунд". """ - text = text.lower() + text = _normalize_timer_text(text.lower()) # Ключевые слова для таймера if not any(word in text for word in ["таймер", "засеки", "поставь таймер"]): return None + if "таймер" in text and re.search( + r"(какие|какой|список|активн|покажи|сколько|есть ли)", text + ): + return self.describe_timers() + if "отмени" in text or "удали" in text: self.cancel_all_timers() return "Хорошо, все таймеры отменены." @@ -128,37 +413,69 @@ class TimerManager: # Пример: "1 час 30 минут", "5 минут", "30 секунд" total_seconds = 0 - found_time = False parts = [] + hours = None + minutes = None + seconds = None + has_fractional = False # Часы match_hours = re.search(r"(\d+)\s*(?:час|часа|часов)", text) if match_hours: - h = int(match_hours.group(1)) - total_seconds += h * 3600 - parts.append(f"{h} ч") - found_time = True + hours = int(match_hours.group(1)) # Минуты match_minutes = re.search(r"(\d+)\s*(?:мин|минуту|минуты|минут)", text) if match_minutes: - m = int(match_minutes.group(1)) - total_seconds += m * 60 - parts.append(f"{m} мин") - found_time = True + minutes = int(match_minutes.group(1)) # Секунды match_seconds = re.search(r"(\d+)\s*(?:сек|секунду|секунды|секунд)", text) if match_seconds: - s = int(match_seconds.group(1)) - total_seconds += s - parts.append(f"{s} сек") - found_time = True + seconds = int(match_seconds.group(1)) + + # Дополняем числительные словами (например, "одну минуту") + word_values = _extract_word_time_values(text) + if hours is None and word_values["hours"] is not None: + hours = word_values["hours"] + if minutes is None and word_values["minutes"] is not None: + minutes = word_values["minutes"] + if seconds is None and word_values["seconds"] is not None: + seconds = word_values["seconds"] + + if hours is not None and isinstance(hours, float) and hours % 1 != 0: + has_fractional = True + if minutes is not None and isinstance(minutes, float) and minutes % 1 != 0: + has_fractional = True + if seconds is not None and isinstance(seconds, float) and seconds % 1 != 0: + has_fractional = True + + found_time = any(value is not None for value in [hours, minutes, seconds]) + if found_time: + total_seconds = ( + (hours or 0) * 3600 + (minutes or 0) * 60 + (seconds or 0) + ) + if has_fractional: + total_seconds = int(round(total_seconds)) + h = total_seconds // 3600 + m = (total_seconds % 3600) // 60 + s = total_seconds % 60 + else: + h = int(hours) if hours is not None else 0 + m = int(minutes) if minutes is not None else 0 + s = int(seconds) if seconds is not None else 0 + + if h: + parts.append(_format_unit(h, "hours")) + if m: + parts.append(_format_unit(m, "minutes")) + if s or not parts: + parts.append(_format_unit(s, "seconds")) if found_time and total_seconds > 0: label = " ".join(parts) self.add_timer(total_seconds, label) - return f"Засек {label}." + return f"Поставил таймер на {label}." # Если сказали "таймер", но не нашли время return "Я не понял, на сколько поставить таймер. Скажите, например, 'таймер на 5 минут'." @@ -172,4 +489,4 @@ def get_timer_manager(): global _timer_manager if _timer_manager is None: _timer_manager = TimerManager() - return _timer_manager \ No newline at end of file + return _timer_manager diff --git a/app/main.py b/app/main.py index 84088d7..dcee4d6 100644 --- a/app/main.py +++ b/app/main.py @@ -47,23 +47,13 @@ from .audio.wakeword import ( from .audio.wakeword import ( stop_monitoring as stop_wakeword_monitoring, ) -from .core.ai import ask_ai, ask_ai_stream, translate_text +from .core.ai import ask_ai_stream, translate_text from .core.cleaner import clean_response +from .core.commands import is_stop_command from .features.alarm import get_alarm_clock from .features.timer import get_timer_manager from .features.weather import get_weather_report - -# Список стоп-слов, чтобы прервать диалог или остановить ассистента -STOP_WORDS = { - "стоп", - "хватит", - "перестань", - "замолчи", - "прекрати", - "тихо", - "stop", -} - +from .features.music import get_music_controller def signal_handler(sig, frame): """ @@ -85,8 +75,22 @@ def parse_translation_request(text: str): Или None, если это не запрос перевода. """ text_lower = text.lower().strip() - # Список префиксов команд перевода и соответствующих направлений языков + # Список префиксов команд перевода и соответствующих направлений языков. + # Важно: более длинные префиксы должны проверяться первыми (например, + # "переведи с русского на английский" не должен схватиться как "переведи с русского"). commands = [ + ("переведи на английский с русского", "ru", "en"), + ("переведи на русский с английского", "en", "ru"), + ("переведи на английский язык с русского", "ru", "en"), + ("переведи на русский язык с английского", "en", "ru"), + ("переведи с русского на английский", "ru", "en"), + ("переведи с русского в английский", "ru", "en"), + ("переведи с английского на русский", "en", "ru"), + ("переведи с английского в русский", "en", "ru"), + ("переведи с русского языка", "ru", "en"), + ("переведи с английского языка", "en", "ru"), + ("переведи на английский язык", "ru", "en"), + ("переведи на русский язык", "en", "ru"), ("переведи на английский", "ru", "en"), ("переведи на русский", "en", "ru"), ("переведи с английского", "en", "ru"), @@ -95,18 +99,25 @@ def parse_translation_request(text: str): ("как по английски", "ru", "en"), ("как по-русски", "en", "ru"), ("как по русски", "en", "ru"), - ("translate to english", "ru", "en"), + ("translate to english from russian", "ru", "en"), + ("translate to russian from english", "en", "ru"), + ("translate from russian to english", "ru", "en"), + ("translate from english to russian", "en", "ru"), ("translate into english", "ru", "en"), - ("translate to russian", "en", "ru"), ("translate into russian", "en", "ru"), + ("translate to english", "ru", "en"), + ("translate to russian", "en", "ru"), ("translate from english", "en", "ru"), ("translate from russian", "ru", "en"), ] - for prefix, source_lang, target_lang in commands: + for prefix, source_lang, target_lang in sorted( + commands, key=lambda item: len(item[0]), reverse=True + ): if text_lower.startswith(prefix): # Отрезаем команду (префикс), оставляем только текст для перевода rest = text[len(prefix) :].strip() + rest = rest.lstrip(" :—-") return { "source_lang": source_lang, "target_lang": target_lang, @@ -115,21 +126,6 @@ def parse_translation_request(text: str): return None -def is_stop_command(text: str) -> bool: - """ - Проверяет, содержится ли в тексте команда остановки. - Удаляет знаки препинания и ищет слова из списка STOP_WORDS. - """ - text_lower = text.lower() - for ch in ",.!?:;": - text_lower = text_lower.replace(ch, " ") - words = text_lower.split() - for word in words: - if word in STOP_WORDS: - return True - return False - - def main(): """ Основная функция (точка входа). @@ -340,6 +336,16 @@ def main(): skip_wakeword = True continue + # Проверка музыкальных команд ("включи музыку", "пауза", и т.д.) + music_controller = get_music_controller() + music_response = music_controller.parse_command(user_text) + if music_response: + clean_music_response = clean_response(music_response, language="ru") + speak(clean_music_response) + last_response = clean_music_response + skip_wakeword = True + continue + # Проверка запроса на перевод translation_request = parse_translation_request(user_text) if translation_request: diff --git a/data/alarms.json b/data/alarms.json index b53d213..2a72af5 100644 --- a/data/alarms.json +++ b/data/alarms.json @@ -23,5 +23,21 @@ "hour": 1, "minute": 19, "active": false + }, + { + "hour": 18, + "minute": 15, + "active": false, + "days": [ + 1 + ] + }, + { + "hour": 18, + "minute": 30, + "active": false, + "days": [ + 1 + ] } ] \ No newline at end of file diff --git a/data/timers.json b/data/timers.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/timers.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a49cf6d..683d78d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -71,3 +71,4 @@ urllib3==2.6.2 websockets==15.0.1 yarl==1.22.0 pygame +spotipy