From cb54a9ee7528bf0ce201defd34bdab33fc5aee5d Mon Sep 17 00:00:00 2001 From: future Date: Sun, 15 Mar 2026 14:40:33 +0300 Subject: [PATCH] feat: improve semantic voice control and music playback --- .env.example | 6 + README.md | 12 +- app/audio/stt.py | 262 ++++++-- app/audio/tts.py | 11 + app/core/ai.py | 138 ++++ app/core/config.py | 5 + app/features/music.py | 1383 ++++++++++++++++++++++++++++++++++------- app/main.py | 115 +++- 8 files changed, 1656 insertions(+), 276 deletions(-) diff --git a/.env.example b/.env.example index a4a03dd..8c3dae5 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,12 @@ TTS_EN_SPEAKER=en_0 WEATHER_LAT=63.56 WEATHER_LON=53.69 WEATHER_CITY=Ухта + +# Navidrome (приоритетный источник музыки; при ошибке — fallback на Spotify) +NAVIDROME_URL=https://navidrome.example.com +NAVIDROME_USERNAME=your_navidrome_username +NAVIDROME_PASSWORD=your_navidrome_password + SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_SECRET=your_spotify_client_secret SPOTIFY_REDIRECT_URI=http://localhost:8888/callback diff --git a/README.md b/README.md index 3fd074e..aef25a6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ - Погода: текущий прогноз по городу по умолчанию или по названию города. - Таймеры, будильники (включая будни/выходные), секундомеры. - Управление громкостью системы (через `pactl`/`amixer`). -- Управление Spotify (play/pause/next/what's playing). +- Управление музыкой через Navidrome (приоритет) с fallback на Spotify. +- Persistent resume: `пауза`/`продолжи` продолжают с сохранённой позиции даже после перезапуска колонки. - Мини-игра "Города". ## Как это работает @@ -60,7 +61,7 @@ flowchart TD ```bash sudo apt-get update -sudo apt-get install -y portaudio19-dev libasound2-dev mpg123 pulseaudio-utils alsa-utils +sudo apt-get install -y portaudio19-dev libasound2-dev mpg123 mpv pulseaudio-utils alsa-utils ``` ### 2) Установка Python-зависимостей @@ -156,6 +157,9 @@ python run.py | `WEATHER_LAT` | Нет | - | Широта города по умолчанию | | `WEATHER_LON` | Нет | - | Долгота города по умолчанию | | `WEATHER_CITY` | Нет | `Ухта` | Город по умолчанию для погоды | +| `NAVIDROME_URL` | Нет | - | URL Navidrome (например `https://navidrome.example.com`) | +| `NAVIDROME_USERNAME` | Нет | - | Логин Navidrome | +| `NAVIDROME_PASSWORD` | Нет | - | Пароль Navidrome | | `SPOTIFY_CLIENT_ID` | Нет | - | Spotify OAuth Client ID | | `SPOTIFY_CLIENT_SECRET` | Нет | - | Spotify OAuth Client Secret | | `SPOTIFY_REDIRECT_URI` | Нет | `http://localhost:8888/callback` | Redirect URI для Spotify | @@ -172,7 +176,7 @@ python run.py | Будильник | `Поставь будильник на 7:30`, `Будильник по будням в 8:00` | | Секундомер | `Запусти секундомер`, `Покажи активные секундомеры` | | Громкость | `Громкость 7` | -| Spotify | `Включи музыку`, `Пауза`, `Что сейчас играет` | +| Музыка (Navidrome first) | `Включи музыку`, `Пауза`, `Продолжи`, `Следующий`, `Предыдущий`, `Что играет`, `Включи жанр electronic`, `Включи папку crystal castles` | | Игра | `Давай сыграем в города` | | Управление диалогом | `Повтори`, `Стоп`, `Хватит` | @@ -222,6 +226,8 @@ alexander_smart-speaker/ | `Audio input/output initialization failed` | проверить, что звук-сервер запущен (PipeWire/PulseAudio), и при необходимости задать `AUDIO_INPUT_DEVICE_NAME`/`AUDIO_OUTPUT_DEVICE_NAME` | | Будильник/таймер не звонит | наличие `mpg123` в системе | | Ошибка про несколько AI API | в `.env` должен остаться только один незакомментированный AI ключ | +| Navidrome не воспроизводит | заполнены `NAVIDROME_*`, доступен `NAVIDROME_URL`, установлен `mpv` | +| Fallback ушёл в Spotify | проверить доступность Navidrome, SSL и корректность `NAVIDROME_USERNAME`/`NAVIDROME_PASSWORD` | | Spotify не управляется | заполнены `SPOTIFY_*`, есть активное устройство, Premium-аккаунт | ## Лицензия diff --git a/app/audio/stt.py b/app/audio/stt.py index 80bfde3..8ee2e43 100644 --- a/app/audio/stt.py +++ b/app/audio/stt.py @@ -12,6 +12,8 @@ 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 deepgram import ( @@ -29,8 +31,12 @@ from ..core.audio_manager import get_audio_manager _original_connect = websockets.sync.client.connect DEEPGRAM_CONNECT_TIMEOUT_SECONDS = 3.0 -DEEPGRAM_CONNECT_WAIT_SECONDS = 1.5 +DEEPGRAM_CONNECT_WAIT_SECONDS = 4.0 DEEPGRAM_CONNECT_POLL_SECONDS = 0.001 +SENDER_STOP_WAIT_SECONDS = 2.5 +SENDER_FORCE_RELEASE_WAIT_SECONDS = 2.5 +DEEPGRAM_FINALIZATION_GRACE_SECONDS = 0.35 +DEEPGRAM_FINISH_TIMEOUT_SECONDS = 4.0 def _patched_connect(*args, **kwargs): @@ -152,6 +158,82 @@ class SpeechRecognizer: ) return self.stream + def _open_stream_for_session(self): + """Открывает отдельный входной поток для одной STT-сессии.""" + if self.audio_manager is None: + self.audio_manager = get_audio_manager() + stream, self._input_device_index, sample_rate = self.audio_manager.open_input_stream( + rate=SAMPLE_RATE, + channels=1, + format=pyaudio.paInt16, + frames_per_buffer=4096, + preferred_index=self._input_device_index, + fallback_rates=[48000, 44100, 32000, 22050, 16000, 8000], + ) + if sample_rate != SAMPLE_RATE: + print( + f"⚠️ STT mic stream uses fallback rate={sample_rate} " + f"(requested {SAMPLE_RATE})" + ) + return stream, int(sample_rate) + + def _stop_stream_quietly(self): + if not self.stream: + return + try: + if self.stream.is_active(): + self.stream.stop_stream() + except Exception: + pass + + def _release_stream(self): + if not self.stream: + return + self._stop_stream_quietly() + try: + self.stream.close() + except Exception: + pass + self.stream = None + + async def _wait_for_thread(self, thread, timeout_seconds: float) -> bool: + """Асинхронно ждет завершения daemon-thread без блокировки event loop.""" + deadline = time.monotonic() + timeout_seconds + while thread.is_alive() and time.monotonic() < deadline: + await asyncio.sleep(0.05) + return not thread.is_alive() + + async def _run_blocking_cleanup(self, func, timeout_seconds: float, label: str) -> bool: + """Запускает потенциально подвисающий cleanup в daemon-thread и ждет ограниченное время.""" + done_event = threading.Event() + error_holder = {} + + def runner(): + try: + func() + except Exception as exc: + error_holder["error"] = exc + finally: + done_event.set() + + thread = threading.Thread(target=runner, daemon=True, name=label) + thread.start() + + deadline = time.monotonic() + timeout_seconds + while not done_event.is_set() and time.monotonic() < deadline: + await asyncio.sleep(0.05) + + if not done_event.is_set(): + print(f"⚠️ {label} timed out; continuing cleanup.") + return False + + error = error_holder.get("error") + if error is not None: + print(f"⚠️ {label} failed: {error}") + return False + + return True + async def _process_audio( self, dg_connection, timeout_seconds, detection_timeout, fast_stop ): @@ -166,9 +248,9 @@ class SpeechRecognizer: """ self.transcript = "" transcript_parts = [] + latest_interim = "" loop = asyncio.get_running_loop() - stream = self._get_stream() effective_detection_timeout = ( detection_timeout if detection_timeout is not None @@ -192,9 +274,13 @@ class SpeechRecognizer: # --- Обработчики событий Deepgram --- def on_transcript(unused_self, result, **kwargs): """Вызывается, когда приходит часть текста.""" + nonlocal latest_interim sentence = result.channel.alternatives[0].transcript if len(sentence) == 0: return + sentence = sentence.strip() + if not sentence: + return try: loop.call_soon_threadsafe(mark_speech_activity) except RuntimeError: @@ -202,9 +288,9 @@ class SpeechRecognizer: if fast_stop: if _is_fast_stop_utterance(sentence): - self.transcript = sentence.strip() + self.transcript = sentence try: - loop.call_soon_threadsafe(stop_event.set) + loop.call_soon_threadsafe(request_stop) except RuntimeError: pass return @@ -213,6 +299,10 @@ class SpeechRecognizer: # Собираем только финальные (подтвержденные) фразы transcript_parts.append(sentence) self.transcript = " ".join(transcript_parts).strip() + latest_interim = "" + else: + # Fallback: некоторые сессии завершаются без is_final. + latest_interim = sentence def on_speech_started(unused_self, speech_started, **kwargs): """Вызывается, когда VAD (Voice Activity Detection) слышит голос.""" @@ -231,7 +321,7 @@ class SpeechRecognizer: def on_error(unused_self, error, **kwargs): print(f"Deepgram Error: {error}") try: - loop.call_soon_threadsafe(stop_event.set) + loop.call_soon_threadsafe(request_stop) except RuntimeError: # Event loop might be closed, ignore pass @@ -242,27 +332,34 @@ class SpeechRecognizer: dg_connection.on(LiveTranscriptionEvents.UtteranceEnd, on_utterance_end) dg_connection.on(LiveTranscriptionEvents.Error, on_error) - # Параметры распознавания - options = LiveOptions( - model="nova-2", # Самая быстрая и точная модель - language=self.current_lang, - smart_format=True, # Расстановка знаков препинания - encoding="linear16", - channels=1, - sample_rate=self._stream_sample_rate, - interim_results=True, - utterance_end_ms=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000), - vad_events=True, - # Сглаженный порог endpointing, чтобы не резать речь на коротких паузах. - endpointing=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000), - ) - # --- Задача отправки аудио с буферизацией --- - async def send_audio(): + sender_stop_event = threading.Event() + + def request_stop(): + stop_event.set() + sender_stop_event.set() + + def send_audio(): chunks_sent = 0 audio_buffer = [] # Буфер для накопления звука во время подключения + stream = None try: + stream, stream_sample_rate = self._open_stream_for_session() + options = LiveOptions( + model="nova-2", # Самая быстрая и точная модель + language=self.current_lang, + smart_format=True, # Расстановка знаков препинания + encoding="linear16", + channels=1, + sample_rate=stream_sample_rate, + interim_results=True, + utterance_end_ms=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000), + vad_events=True, + # Сглаженный порог endpointing, чтобы не резать речь на коротких паузах. + endpointing=int(POST_SPEECH_SILENCE_TIMEOUT_SECONDS * 1000), + ) + # 1. Сразу начинаем захват звука, не дожидаясь сети! stream.start_stream() print("🎤 Stream started (buffering)...") @@ -270,34 +367,61 @@ class SpeechRecognizer: # 2. Запускаем подключение к Deepgram в фоне (через ThreadPool, т.к. start() блокирующий) # Но в данном SDK start() возвращает bool, он может быть блокирующим. # Deepgram Python SDK v3+ start() делает handshake. + connect_result = {"done": False, "ok": None, "error": None} - connect_future = loop.run_in_executor( - None, lambda: dg_connection.start(options) + def start_connection(): + try: + connect_result["ok"] = dg_connection.start(options) + except Exception as exc: + connect_result["error"] = exc + finally: + connect_result["done"] = True + + connect_thread = threading.Thread( + target=start_connection, daemon=True ) + connect_thread.start() # Пока подключаемся, копим данные. # Ждём коротко: если сеть подвисла, быстрее перезапускаем попытку. connect_deadline = time.monotonic() + DEEPGRAM_CONNECT_WAIT_SECONDS while ( - not connect_future.done() + not connect_result["done"] and time.monotonic() < connect_deadline + and not sender_stop_event.is_set() ): if stream.is_active(): - data = stream.read(4096, exception_on_overflow=False) + try: + data = stream.read(4096, exception_on_overflow=False) + except Exception as read_error: + if sender_stop_event.is_set(): + return + print(f"Audio read error during connect: {read_error}") + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(request_stop) + return audio_buffer.append(data) - await asyncio.sleep(DEEPGRAM_CONNECT_POLL_SECONDS) + time.sleep(DEEPGRAM_CONNECT_POLL_SECONDS) - if not connect_future.done(): + if sender_stop_event.is_set(): + return + + if not connect_result["done"]: print( f"⏰ Timeout connecting to Deepgram ({DEEPGRAM_CONNECT_WAIT_SECONDS:.1f}s)" ) - stop_event.set() + loop.call_soon_threadsafe(request_stop) return # Проверяем результат подключения - if connect_future.result() is False: + if connect_result["error"] is not None: + print(f"Failed to start Deepgram connection: {connect_result['error']}") + loop.call_soon_threadsafe(request_stop) + return + + if connect_result["ok"] is False: print("Failed to start Deepgram connection") - stop_event.set() + loop.call_soon_threadsafe(request_stop) return print(f"🚀 Connected! Sending buffer ({len(audio_buffer)} chunks)...") @@ -310,23 +434,45 @@ class SpeechRecognizer: audio_buffer = None # Освобождаем память # 4. Продолжаем стримить в реальном времени до события остановки. - while not stop_event.is_set(): - if stream.is_active(): + while not sender_stop_event.is_set(): + if not stream.is_active(): + break + try: data = stream.read(4096, exception_on_overflow=False) - dg_connection.send(data) - chunks_sent += 1 - if chunks_sent % 50 == 0: - print(".", end="", flush=True) - await asyncio.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования + except Exception as read_error: + if sender_stop_event.is_set(): + break + print(f"Audio read error: {read_error}") + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(request_stop) + break + if sender_stop_event.is_set(): + break + dg_connection.send(data) + chunks_sent += 1 + if chunks_sent % 50 == 0: + print(".", end="", flush=True) + time.sleep(0.002) # Уменьшаем задержку для более быстрого реагирования except Exception as e: print(f"Audio send error: {e}") + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(request_stop) finally: - if stream.is_active(): - stream.stop_stream() + with contextlib.suppress(Exception): + if stream and stream.is_active(): + stream.stop_stream() + with contextlib.suppress(Exception): + if stream: + stream.close() print(f"\n🛑 Stream stopped. Chunks sent: {chunks_sent}") - sender_task = asyncio.create_task(send_audio()) + sender_thread = threading.Thread( + target=send_audio, + daemon=True, + name="deepgram-audio-sender", + ) + sender_thread.start() if False: # dg_connection.start(options) перенесен внутрь send_audio pass @@ -356,7 +502,7 @@ class SpeechRecognizer: if not done: # Если за detection_timeout никто не начал говорить, выходим - stop_event.set() + request_stop() # 2. После старта речи завершаем только по тишине POST_SPEECH_SILENCE_TIMEOUT_SECONDS. # Добавляем длинный защитный лимит, чтобы сессия не зависла навсегда. @@ -374,7 +520,7 @@ class SpeechRecognizer: now - last_speech_activity >= POST_SPEECH_SILENCE_TIMEOUT_SECONDS ): - stop_event.set() + request_stop() break if ( @@ -383,7 +529,7 @@ class SpeechRecognizer: >= max_active_speech_seconds ): print("⏱️ Достигнут защитный лимит активного прослушивания.") - stop_event.set() + request_stop() break await asyncio.sleep(0.05) @@ -393,19 +539,29 @@ class SpeechRecognizer: except Exception as e: print(f"Error in waiting for events: {e}") - stop_event.set() - try: - await sender_task - except Exception as e: - print(f"Error waiting for sender task: {e}") + request_stop() + sender_stopped = await self._wait_for_thread( + sender_thread, + timeout_seconds=max(SENDER_STOP_WAIT_SECONDS, SENDER_FORCE_RELEASE_WAIT_SECONDS), + ) + if not sender_stopped: + print("⚠️ Audio sender shutdown timed out; continuing cleanup.") + + # Небольшая пауза, чтобы получить последние transcript-события перед finish(). + await asyncio.sleep(DEEPGRAM_FINALIZATION_GRACE_SECONDS) # Завершаем соединение и ждем последние результаты - try: - dg_connection.finish() - except Exception as e: - print(f"Error finishing connection: {e}") + await self._run_blocking_cleanup( + dg_connection.finish, + timeout_seconds=DEEPGRAM_FINISH_TIMEOUT_SECONDS, + label="Deepgram finish", + ) - return self.transcript + final_text = self.transcript.strip() + if not final_text: + final_text = latest_interim.strip() + self.transcript = final_text + return final_text def listen( self, diff --git a/app/audio/tts.py b/app/audio/tts.py index 6b6fbbb..5c164b1 100644 --- a/app/audio/tts.py +++ b/app/audio/tts.py @@ -286,6 +286,9 @@ class TextToSpeech: if not text.strip(): return True + if check_interrupt is None: + check_interrupt = self._default_interrupt_checker() + if language == "ru": text = self._preprocess_text(text) segments = self._split_mixed_language(text) @@ -296,6 +299,14 @@ class TextToSpeech: text, check_interrupt=check_interrupt, language=language ) + def _default_interrupt_checker(self): + try: + from .wakeword import check_wakeword_once + + return check_wakeword_once + except Exception: + return None + def _resample_audio(self, audio_np: np.ndarray, src_rate: int, dst_rate: int): if src_rate == dst_rate: return audio_np.astype(np.float32, copy=False) diff --git a/app/core/ai.py b/app/core/ai.py index b9275cf..ee4aa4e 100644 --- a/app/core/ai.py +++ b/app/core/ai.py @@ -54,6 +54,26 @@ No explanations, no quotes, no comments. Separate variants with " / " (space slash space). Keep the translation максимально кратким и естественным, без лишних слов.""" +INTENT_SYSTEM_PROMPT = """Ты NLU-модуль голосовой колонки. +Твоя задача: распознать намерение пользователя и вернуть СТРОГО JSON без markdown и пояснений. +Всегда возвращай объект c ключами: +{ + "intent": "none|music|timer|alarm|weather|volume|translation|cities|repeat|stop|smalltalk|chat", + "normalized_command": "<краткая нормализованная команда на русском или пусто>", + "music_action": "none|play|pause|resume|next|previous|current|play_genre|play_folder|play_query", + "music_query": "<запрос для музыки/жанра/папки или пусто>", + "confidence": 0.0 +} +Правила: +- Если это музыка, ставь intent=music и выбирай music_action. +- "Включи музыку" и любые эквиваленты = music_action=play. +- Для "пауза/останови музыку/выключи музыку" = music_action=pause. +- Для "что играет" = music_action=current. +- Для "включи жанр X" = music_action=play_genre, music_query=X. +- Для "включи папку X" = music_action=play_folder, music_query=X. +- normalized_command должен быть пригоден для командного парсера (без лишних слов). +- Если уверенность низкая, ставь intent=none, music_action=none, confidence <= 0.4.""" + _PROVIDER_ALIASES = { "": "openrouter", "anthropic": "anthropic", @@ -381,6 +401,32 @@ def _log_request_exception(cfg, error: Exception): print(f"❌ Ошибка API ({cfg['name']}): {error}{details}") +def _extract_json_object(raw_text: str) -> Optional[dict]: + text = str(raw_text or "").strip() + if not text: + return None + + try: + payload = json.loads(text) + if isinstance(payload, dict): + return payload + except json.JSONDecodeError: + pass + + match = re.search(r"\{.*\}", text, flags=re.DOTALL) + if not match: + return None + + candidate = match.group(0).strip() + try: + payload = json.loads(candidate) + except json.JSONDecodeError: + return None + if isinstance(payload, dict): + return payload + return None + + def _send_request(messages, max_tokens, temperature, error_text): """ Внутренняя функция для отправки HTTP-запроса к выбранному AI-провайдеру. @@ -422,6 +468,98 @@ def _send_request(messages, max_tokens, temperature, error_text): return "Не удалось обработать ответ от AI." +def interpret_assistant_intent(text: str) -> dict: + """ + Interprets voice command semantics for downstream command routers. + Returns a normalized dict even when AI is unavailable. + """ + result = { + "intent": "none", + "normalized_command": "", + "music_action": "none", + "music_query": "", + "confidence": 0.0, + } + cleaned_text = str(text or "").strip() + if not cleaned_text: + return result + + cfg, selection_error = _get_provider_settings() + if selection_error: + return result + if _get_provider_config_error(cfg): + return result + + messages = [ + {"role": "system", "content": INTENT_SYSTEM_PROMPT}, + {"role": "user", "content": cleaned_text}, + ] + response = _send_request( + messages, + max_tokens=220, + temperature=0.0, + error_text="", + ) + payload = _extract_json_object(response) + if not payload: + return result + + allowed_intents = { + "none", + "music", + "timer", + "alarm", + "weather", + "volume", + "translation", + "cities", + "repeat", + "stop", + "smalltalk", + "chat", + } + allowed_music_actions = { + "none", + "play", + "pause", + "resume", + "next", + "previous", + "current", + "play_genre", + "play_folder", + "play_query", + } + + intent = str(payload.get("intent", "none")).strip().lower() + if intent not in allowed_intents: + intent = "none" + + music_action = str(payload.get("music_action", "none")).strip().lower() + if music_action not in allowed_music_actions: + music_action = "none" + + try: + confidence = float(payload.get("confidence", 0.0)) + except (TypeError, ValueError): + confidence = 0.0 + confidence = max(0.0, min(1.0, confidence)) + + normalized_command = str(payload.get("normalized_command", "")).strip() + music_query = str(payload.get("music_query", "")).strip() + + result.update( + { + "intent": intent, + "normalized_command": normalized_command, + "music_action": music_action, + "music_query": music_query, + "confidence": confidence, + } + ) + return result + + def ask_ai(messages_history: list) -> str: """ Запрос к AI в режиме чата. diff --git a/app/core/config.py b/app/core/config.py index 201dea0..90ffeb4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -129,3 +129,8 @@ TTS_SAMPLE_RATE = 48000 WEATHER_LAT = os.getenv("WEATHER_LAT") WEATHER_LON = os.getenv("WEATHER_LON") WEATHER_CITY = os.getenv("WEATHER_CITY", "Ухта") + +# --- Настройки Navidrome (музыка) --- +NAVIDROME_URL = os.getenv("NAVIDROME_URL", "").strip().rstrip("/") +NAVIDROME_USERNAME = os.getenv("NAVIDROME_USERNAME", "").strip() +NAVIDROME_PASSWORD = os.getenv("NAVIDROME_PASSWORD", "") diff --git a/app/features/music.py b/app/features/music.py index 4652166..a0009d9 100644 --- a/app/features/music.py +++ b/app/features/music.py @@ -1,20 +1,26 @@ -""" -Spotify Music Controller -Модуль для управления воспроизведением музыки через Spotify API. +"""Music controller with Navidrome-first playback and Spotify fallback.""" -Поддерживаемые команды: -- "включи музыку" / "play music" - воспроизведение топ-треков или по запросу -- "пауза" / "стоп музыка" - пауза -- "продолжи" / "дальше" - возобновление -- "следующий трек" / "next" - следующий трек -- "предыдущий трек" / "previous" - предыдущий трек -- "что играет" / "какая песня" - информация о текущем треке -- "угадай песню" / "распознай музыку" - распознавание текущего трека -""" +from __future__ import annotations +import difflib +import hashlib +import json import os +import random import re -from typing import Optional +import shutil +import socket +import subprocess +import threading +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Callable, Optional +from urllib.parse import urlencode + +import requests + +from ..core.config import BASE_DIR, WAKE_WORD_ALIASES try: import spotipy @@ -23,14 +29,62 @@ except ImportError: spotipy = None SpotifyOAuth = None -# Singleton instance -_music_controller = None + +STATE_FILE = BASE_DIR / "data" / "music_state.json" +MPV_SOCKET_FILE = BASE_DIR / "data" / "music_mpv.sock" -class SpotifyMusicController: - """Контроллер для управления Spotify воспроизведением.""" +class NavidromeUnavailableError(Exception): + """Raised when Navidrome cannot be used (network/auth/config).""" + + +class NavidromeCommandError(Exception): + """Raised for valid command paths with domain-level errors (e.g. no genre).""" + + +@dataclass +class Song: + """Minimal song payload for queue/state handling.""" + + song_id: str + title: str + artist: str + album: str + duration: int + path: str + genre: str + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "Song": + return cls( + song_id=str(payload.get("id", "")).strip(), + title=str(payload.get("title", "Неизвестный трек")).strip(), + artist=str(payload.get("artist", "Неизвестный артист")).strip(), + album=str(payload.get("album", "")).strip(), + duration=int(payload.get("duration") or 0), + path=str(payload.get("path", "")).strip(), + genre=str(payload.get("genre", "")).strip(), + ) + + @classmethod + def from_state(cls, payload: dict[str, Any]) -> "Song": + return cls( + song_id=str(payload.get("song_id", payload.get("id", ""))).strip(), + title=str(payload.get("title", "Неизвестный трек")).strip(), + artist=str(payload.get("artist", "Неизвестный артист")).strip(), + album=str(payload.get("album", "")).strip(), + duration=int(payload.get("duration") or 0), + path=str(payload.get("path", "")).strip(), + genre=str(payload.get("genre", "")).strip(), + ) + + def to_state(self) -> dict[str, Any]: + return asdict(self) + + +class SpotifyProvider: + """Spotify provider kept as fallback when Navidrome is unavailable.""" - # Scopes для Spotify API SCOPES = [ "user-read-playback-state", "user-modify-playback-state", @@ -39,26 +93,22 @@ class SpotifyMusicController: "streaming", ] - def __init__(self): - """Инициализация контроллера Spotify.""" + def __init__(self) -> None: 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" + 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") @@ -77,264 +127,1179 @@ class SpotifyMusicController: 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}") + except Exception as exc: + self._init_error = str(exc) + print(f"❌ Spotify: ошибка инициализации - {exc}") 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"] + for device in devices.get("devices", []): + if device.get("is_active"): + return device.get("id") + if devices.get("devices"): + return devices["devices"][0].get("id") except Exception: - pass + return None 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 ''}" + return f"Не удалось подключиться к Spotify. {self._init_error or ''}".strip() 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}" + self.sp.start_playback(device_id=device_id, uris=[track["uri"]]) + return f"В Spotify включаю {track['name']} от {track['artists'][0]['name']}" + + 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"В Spotify включаю музыку. Сейчас {first_track['name']}" + + self.sp.start_playback(device_id=device_id) + return "В Spotify включаю музыку" + except Exception as exc: + return f"Spotify: {exc}" 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}" + return "Spotify: музыка на паузе" + except Exception as exc: + return f"Spotify: {exc}" + + def pause_toggle(self) -> str: + """Toggle pause for repeated 'pause' voice command.""" + if not self._ensure_initialized(): + return "Spotify не подключён" + try: + current = self.sp.current_playback() + if current and current.get("is_playing"): + self.sp.pause_playback() + return "Spotify: музыка на паузе" + if current and current.get("item"): + self.sp.start_playback() + return "Spotify: продолжаю воспроизведение" + self.sp.pause_playback() + return "Spotify: музыка на паузе" + except Exception as exc: + return f"Spotify: {exc}" 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}" + return "Spotify: продолжаю воспроизведение" + except Exception as exc: + return f"Spotify: {exc}" 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}" + return self.get_current_track() or "Spotify: следующий трек" + except Exception as exc: + return f"Spotify: {exc}" 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}" + return self.get_current_track() or "Spotify: предыдущий трек" + except Exception as exc: + return f"Spotify: {exc}" - def get_current_track(self) -> Optional[str]: - """Получить информацию о текущем треке.""" + def get_current_track(self) -> 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}" + status = "Сейчас играет" if current.get("is_playing") else "На паузе" + artists = ", ".join(artist["name"] for artist in track.get("artists", [])) + return f"Spotify. {status}: {track.get('name', 'Трек')} от {artists}" + return "Spotify: сейчас ничего не играет" + except Exception as exc: + return f"Spotify: {exc}" + + +class NavidromeProvider: + """Primary provider using Navidrome + MPV IPC.""" + + def __init__(self) -> None: + self.base_url = os.getenv("NAVIDROME_URL", "").strip().rstrip("/") + self.username = os.getenv("NAVIDROME_USERNAME", "").strip() + self.password = os.getenv("NAVIDROME_PASSWORD", "") + + self._initialized = False + self._init_error = "" + self._state_lock = threading.Lock() + self._mpv_process: Optional[subprocess.Popen] = None + self._mpv_socket = str(MPV_SOCKET_FILE) + + self._genres_cache: tuple[float, list[str]] = (0.0, []) + self._folder_index: dict[str, dict[str, Any]] = {} + self._folder_index_built_at = 0.0 + self._autonext_lock = threading.Lock() + self._autonext_suppress_until = 0.0 + + self._snapshot_stop = threading.Event() + self._snapshot_thread = threading.Thread( + target=self._snapshot_loop, + name="music-mpv-snapshot", + daemon=True, + ) + self._snapshot_thread.start() + + self._state = self._load_state() + + def initialize(self) -> bool: + if self._initialized: + return True + + if not self.base_url or not self.username or not self.password: + self._init_error = ( + "Не заданы NAVIDROME_URL, NAVIDROME_USERNAME или NAVIDROME_PASSWORD" + ) + return False + + if shutil.which("mpv") is None: + self._init_error = "Не найден mpv. Установите: sudo apt install mpv" + return False + + try: + self._request_json("ping.view") + except Exception as exc: + self._init_error = f"Ошибка подключения к Navidrome: {exc}" + return False + + self._initialized = True + print("✅ Navidrome: подключено") + return True + + def _default_state(self) -> dict[str, Any]: + return { + "current_song": None, + "queue": [], + "queue_index": -1, + "position_sec": 0.0, + "paused": False, + "last_action": "", + "source": "navidrome", + "updated_at": "", + } + + def _load_state(self) -> dict[str, Any]: + state = self._default_state() + if not STATE_FILE.exists(): + return state + + try: + with open(STATE_FILE, "r", encoding="utf-8") as file: + raw = json.load(file) + except Exception as exc: + print(f"⚠️ Музыка: не удалось загрузить состояние: {exc}") + return state + + if not isinstance(raw, dict): + return state + + for key in state: + if key in raw: + state[key] = raw[key] + + state["queue"] = state["queue"] if isinstance(state["queue"], list) else [] + state["queue_index"] = int(state.get("queue_index") or -1) + try: + state["position_sec"] = float(state.get("position_sec") or 0.0) + except Exception: + state["position_sec"] = 0.0 + state["paused"] = bool(state.get("paused")) + return state + + def _save_state(self) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + with self._state_lock: + payload = dict(self._state) + try: + with open(STATE_FILE, "w", encoding="utf-8") as file: + json.dump(payload, file, ensure_ascii=False, indent=2) + except Exception as exc: + print(f"⚠️ Музыка: не удалось сохранить состояние: {exc}") + + def _set_state(self, **updates: Any) -> None: + with self._state_lock: + self._state.update(updates) + self._state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") + self._save_state() + + def _get_state_value(self, key: str, default: Any = None) -> Any: + with self._state_lock: + return self._state.get(key, default) + + def _current_song(self) -> Optional[Song]: + payload = self._get_state_value("current_song") + if not isinstance(payload, dict): + return None + song = Song.from_state(payload) + if not song.song_id: + return None + return song + + def _queue(self) -> list[Song]: + queue_raw = self._get_state_value("queue", []) + if not isinstance(queue_raw, list): + return [] + + songs: list[Song] = [] + for item in queue_raw: + if isinstance(item, dict): + song = Song.from_state(item) + if song.song_id: + songs.append(song) + return songs + + def _set_queue(self, queue: list[Song], index: int) -> None: + safe_index = max(0, min(index, len(queue) - 1)) if queue else -1 + current_song = queue[safe_index].to_state() if queue else None + self._set_state( + queue=[song.to_state() for song in queue], + queue_index=safe_index, + current_song=current_song, + position_sec=0.0, + paused=False, + source="navidrome", + ) + + def _auth_params(self, include_format: bool = True) -> dict[str, str]: + salt = os.urandom(6).hex() + token = hashlib.md5(f"{self.password}{salt}".encode("utf-8")).hexdigest() + params = { + "u": self.username, + "t": token, + "s": salt, + "v": "1.16.1", + "c": "alexander-smart-speaker", + } + if include_format: + params["f"] = "json" + return params + + def _request_json( + self, + endpoint: str, + params: Optional[dict[str, Any]] = None, + timeout: int = 20, + ) -> dict[str, Any]: + if not self.base_url: + raise NavidromeUnavailableError("NAVIDROME_URL не настроен") + + url = f"{self.base_url}/rest/{endpoint}" + full_params = self._auth_params(include_format=True) + if params: + full_params.update(params) + + try: + response = requests.get(url, params=full_params, timeout=timeout) + response.raise_for_status() + payload = response.json() + except Exception as exc: + raise NavidromeUnavailableError(f"Navidrome API недоступен: {exc}") from exc + + subsonic = payload.get("subsonic-response", {}) + if subsonic.get("status") != "ok": + error = subsonic.get("error", {}) + message = error.get("message") or "Неизвестная ошибка Navidrome" + raise NavidromeUnavailableError(message) + return subsonic + + def _stream_url(self, song_id: str) -> str: + params = self._auth_params(include_format=False) + params["id"] = song_id + query = urlencode(params) + return f"{self.base_url}/rest/stream.view?{query}" + + def _ensure_initialized(self) -> None: + if not self.initialize(): + raise NavidromeUnavailableError(self._init_error or "Navidrome недоступен") + + def _ensure_list(self, value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + def _normalize(self, text: str) -> str: + text = text.lower().replace("ё", "е").strip() + text = re.sub(r"[^a-zа-я0-9/\\\s_-]+", " ", text) + text = re.sub(r"\s+", " ", text).strip() + return text + + def _fuzzy_candidates(self, query: str, values: list[str]) -> list[str]: + normalized_query = self._normalize(query) + if not normalized_query: + return [] + + scored: list[tuple[float, str]] = [] + for value in values: + normalized_value = self._normalize(value) + if not normalized_value: + continue + + ratio = difflib.SequenceMatcher( + None, + normalized_query, + normalized_value, + ).ratio() + score = ratio + if normalized_query in normalized_value: + score += 0.4 + if normalized_value in normalized_query: + score += 0.2 + scored.append((score, value)) + + if not scored: + return [] + + scored.sort(key=lambda item: item[0], reverse=True) + best_score = scored[0][0] + threshold = max(0.45, best_score - 0.08) + + picked = [value for score, value in scored if score >= threshold] + if not picked and scored[0][0] >= 0.35: + return [scored[0][1]] + return picked + + def _get_random_queue(self, size: int = 20) -> list[Song]: + response = self._request_json("getRandomSongs.view", {"size": size}) + songs = self._ensure_list(response.get("randomSongs", {}).get("song")) + queue = [Song.from_payload(song) for song in songs if song.get("id")] + if not queue: + raise NavidromeCommandError("В Navidrome не найдено треков для воспроизведения") + random.shuffle(queue) + return queue + + def _fetch_genres(self) -> list[str]: + cache_timestamp, genres = self._genres_cache + if genres and (time.time() - cache_timestamp < 600): + return genres + + response = self._request_json("getGenres.view") + genre_items = self._ensure_list(response.get("genres", {}).get("genre")) + genre_values = [str(item.get("value", "")).strip() for item in genre_items] + genre_values = [item for item in genre_values if item] + self._genres_cache = (time.time(), genre_values) + return genre_values + + def _build_folder_index(self) -> None: + if self._folder_index and (time.time() - self._folder_index_built_at < 900): + return + + albums: list[dict[str, Any]] = [] + offset = 0 + page_size = 200 + + while True: + response = self._request_json( + "getAlbumList2.view", + { + "type": "alphabeticalByName", + "size": page_size, + "offset": offset, + }, + ) + page = self._ensure_list(response.get("albumList2", {}).get("album")) + if not page: + break + albums.extend(page) + if len(page) < page_size: + break + offset += page_size + + if not albums: + self._folder_index = {} + self._folder_index_built_at = time.time() + return + + index: dict[str, dict[str, Any]] = {} + + def add_key(raw_key: str, song: Song) -> None: + normalized = self._normalize(raw_key) + if not normalized: + return + item = index.setdefault(normalized, {"name": raw_key.strip(), "songs": {}}) + item["songs"][song.song_id] = song + + for album in albums: + album_id = str(album.get("id", "")).strip() + if not album_id: + continue + + album_payload = self._request_json("getAlbum.view", {"id": album_id}) + song_items = self._ensure_list(album_payload.get("album", {}).get("song")) + + for raw_song in song_items: + if not raw_song.get("id"): + continue + song = Song.from_payload(raw_song) + folder_path = song.path.replace("\\", "/") + segments = [segment for segment in folder_path.split("/") if segment] + if len(segments) <= 1: + continue + + folders = segments[:-1] + for idx, segment in enumerate(folders): + add_key(segment, song) + add_key("/".join(folders[: idx + 1]), song) + + self._folder_index = index + self._folder_index_built_at = time.time() + + def _songs_by_genre(self, query: str) -> tuple[list[str], list[Song]]: + genres = self._fetch_genres() + matched = self._fuzzy_candidates(query, genres) + if not matched: + raise NavidromeCommandError(f"Жанр '{query}' не найден в Navidrome") + + songs_map: dict[str, Song] = {} + for genre in matched: + response = self._request_json( + "getSongsByGenre.view", + {"genre": genre, "count": 300}, + ) + songs = self._ensure_list(response.get("songsByGenre", {}).get("song")) + for raw_song in songs: + if raw_song.get("id"): + song = Song.from_payload(raw_song) + songs_map[song.song_id] = song + + if not songs_map: + raise NavidromeCommandError(f"В жанре '{matched[0]}' нет доступных треков") + + songs_list = list(songs_map.values()) + random.shuffle(songs_list) + return matched, songs_list + + def _songs_by_folder(self, query: str) -> tuple[list[str], list[Song]]: + self._build_folder_index() + if not self._folder_index: + raise NavidromeCommandError("В Navidrome не удалось построить индекс папок") + + matched = self._fuzzy_candidates(query, list(self._folder_index.keys())) + if not matched: + raise NavidromeCommandError(f"Папка '{query}' не найдена в библиотеке") + + songs_map: dict[str, Song] = {} + matched_labels: list[str] = [] + + for key in matched: + entry = self._folder_index.get(key) + if not entry: + continue + matched_labels.append(entry.get("name", key)) + for song_id, song in entry.get("songs", {}).items(): + songs_map[song_id] = song + + if not songs_map: + raise NavidromeCommandError(f"В папке '{query}' нет доступных треков") + + songs_list = list(songs_map.values()) + random.shuffle(songs_list) + return matched_labels, songs_list + + def _search_songs(self, query: str, count: int = 50) -> list[Song]: + response = self._request_json( + "search3.view", + { + "query": query, + "songCount": count, + "artistCount": 0, + "albumCount": 0, + }, + ) + songs = self._ensure_list(response.get("searchResult3", {}).get("song")) + result = [Song.from_payload(song) for song in songs if song.get("id")] + random.shuffle(result) + return result + + def _remove_stale_socket(self) -> None: + socket_path = Path(self._mpv_socket) + if socket_path.exists(): + try: + socket_path.unlink() + except Exception: + pass + + def _is_mpv_alive(self) -> bool: + if self._mpv_process is None: + return False + + exit_code = self._mpv_process.poll() + if exit_code is None: + return True + + self._mpv_process = None + self._remove_stale_socket() + return False + + def _suppress_autonext(self, seconds: float = 2.0) -> None: + self._autonext_suppress_until = max(self._autonext_suppress_until, time.time() + seconds) + + def _autonext_allowed(self) -> bool: + return time.time() >= self._autonext_suppress_until + + def _auto_advance_if_track_finished(self) -> None: + if self._is_mpv_alive(): + return + if not self._autonext_allowed(): + return + if bool(self._get_state_value("paused", False)): + return + + if not self._autonext_lock.acquire(blocking=False): + return + + try: + if self._is_mpv_alive(): + return + if not self._autonext_allowed(): + return + if bool(self._get_state_value("paused", False)): + return + + queue = self._queue() + if not queue: + if self._current_song() is not None: + self._set_state(current_song=None, position_sec=0.0, paused=False) + return + + current_index = int(self._get_state_value("queue_index", -1) or -1) + next_index = (current_index + 1) % len(queue) if current_index >= 0 else 0 + song = self._play_queue_index(queue, next_index, start_sec=0.0) + self._set_state(last_action="autonext") + print(f"⏭️ Автопереход: {song.title} от {song.artist}") + except Exception as exc: + self._suppress_autonext(seconds=5.0) + print(f"⚠️ Автопереход на следующий трек не удался: {exc}") + finally: + self._autonext_lock.release() + + def _mpv_wait_for_ipc(self, timeout: float = 3.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if os.path.exists(self._mpv_socket): + return True + time.sleep(0.05) + return False + + def _mpv_ipc(self, command: list[Any]) -> Any: + if not os.path.exists(self._mpv_socket): + raise NavidromeUnavailableError("IPC сокет mpv недоступен") + + request_payload = json.dumps({"command": command}, ensure_ascii=False) + "\n" + + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(1.5) + client.connect(self._mpv_socket) + client.sendall(request_payload.encode("utf-8")) + response = client.recv(8192).decode("utf-8", errors="ignore").strip() + except Exception as exc: + raise NavidromeUnavailableError(f"Ошибка IPC mpv: {exc}") from exc + + if not response: + return None + + try: + payload = json.loads(response) + except json.JSONDecodeError: + return None + + error_value = payload.get("error") + if error_value not in (None, "success", "property unavailable"): + raise NavidromeUnavailableError(f"Ошибка mpv: {error_value}") + return payload.get("data") + + def _sync_position_snapshot(self) -> None: + if not self._is_mpv_alive(): + return + + try: + paused = bool(self._mpv_ipc(["get_property", "pause"])) + except Exception: + paused = bool(self._get_state_value("paused", False)) + + try: + position = float(self._mpv_ipc(["get_property", "time-pos"]) or 0.0) + except Exception: + position = float(self._get_state_value("position_sec", 0.0) or 0.0) + + self._set_state(paused=paused, position_sec=max(0.0, position)) + + def _snapshot_loop(self) -> None: + while not self._snapshot_stop.wait(1.0): + try: + self._sync_position_snapshot() + self._auto_advance_if_track_finished() + except Exception: + continue + + def _stop_mpv(self) -> None: + self._suppress_autonext(seconds=2.0) + if self._is_mpv_alive(): + try: + self._mpv_ipc(["quit"]) + except Exception: + pass + + if self._mpv_process is not None: + try: + self._mpv_process.terminate() + self._mpv_process.wait(timeout=2) + except Exception: + try: + self._mpv_process.kill() + except Exception: + pass + finally: + self._mpv_process = None + + self._remove_stale_socket() + + def _start_song(self, song: Song, start_sec: float = 0.0) -> None: + self._ensure_initialized() + self._suppress_autonext(seconds=2.0) + self._stop_mpv() + self._remove_stale_socket() + + stream_url = self._stream_url(song.song_id) + command = [ + "mpv", + "--no-video", + "--quiet", + "--force-window=no", + f"--input-ipc-server={self._mpv_socket}", + ] + if start_sec > 0: + command.append(f"--start={start_sec:.3f}") + command.append(stream_url) + + self._mpv_process = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + if not self._mpv_wait_for_ipc(): + raise NavidromeUnavailableError("Не удалось запустить mpv IPC") + + self._set_state( + current_song=song.to_state(), + position_sec=max(0.0, float(start_sec)), + paused=False, + source="navidrome", + ) + + def _play_queue_index(self, queue: list[Song], index: int, start_sec: float = 0.0) -> Song: + if not queue: + raise NavidromeCommandError("Очередь воспроизведения пуста") + + safe_index = max(0, min(index, len(queue) - 1)) + song = queue[safe_index] + self._start_song(song, start_sec=start_sec) + self._set_state( + queue=[item.to_state() for item in queue], + queue_index=safe_index, + current_song=song.to_state(), + position_sec=max(0.0, float(start_sec)), + paused=False, + source="navidrome", + ) + return song + + def can_resume_context(self) -> bool: + if self._is_mpv_alive(): + try: + paused = bool(self._mpv_ipc(["get_property", "pause"])) + if paused: + return True + except Exception: + return False + + current_song = self._current_song() + if not current_song: + return False + + return ( + bool(self._get_state_value("paused", False)) + and str(self._get_state_value("last_action", "")).lower() == "pause" + ) + + def play_random(self, contextual_resume: bool = False) -> str: + self._ensure_initialized() + + if contextual_resume and self.can_resume_context(): + return self.resume() + + queue = self._get_random_queue(size=20) + song = self._play_queue_index(queue, 0, start_sec=0.0) + self._set_state(last_action="play_random") + return f"Включаю случайный трек: {song.title} от {song.artist}" + + def play_query(self, query: str) -> str: + self._ensure_initialized() + songs = self._search_songs(query, count=50) + if not songs: + raise NavidromeCommandError(f"Не нашёл трек по запросу '{query}' в Navidrome") + + index = random.randrange(len(songs)) + song = self._play_queue_index(songs, index, start_sec=0.0) + self._set_state(last_action="play_query") + return f"Включаю {song.title} от {song.artist}" + + def play_genre(self, genre_query: str) -> str: + self._ensure_initialized() + matched_genres, songs = self._songs_by_genre(genre_query) + song = self._play_queue_index(songs, 0, start_sec=0.0) + self._set_state(last_action="play_genre") + genre_label = matched_genres[0] + return f"Включаю жанр {genre_label}. Сейчас {song.title} от {song.artist}" + + def play_folder(self, folder_query: str) -> str: + self._ensure_initialized() + matched_folders, songs = self._songs_by_folder(folder_query) + song = self._play_queue_index(songs, 0, start_sec=0.0) + self._set_state(last_action="play_folder") + folder_label = matched_folders[0] if matched_folders else folder_query + return f"Включаю папку {folder_label}. Сейчас {song.title} от {song.artist}" + + def _pause_impl(self, *, toggle: bool) -> str: + self._ensure_initialized() + + if self._is_mpv_alive(): + try: + paused_now = bool(self._mpv_ipc(["get_property", "pause"])) + except Exception: + paused_now = bool(self._get_state_value("paused", False)) + + if paused_now: + if toggle: + self._mpv_ipc(["set_property", "pause", False]) + self._set_state(paused=False, last_action="resume") + song = self._current_song() + if song: + return f"Продолжаю: {song.title} от {song.artist}" + return "Продолжаю воспроизведение" + self._sync_position_snapshot() + self._set_state(paused=True, last_action="pause") + return "Уже на паузе." + + self._mpv_ipc(["set_property", "pause", True]) + time.sleep(0.1) + position = float(self._mpv_ipc(["get_property", "time-pos"]) or 0.0) + self._set_state(paused=True, position_sec=max(0.0, position), last_action="pause") + return "Пауза. Продолжу с того же места." + + if self._current_song(): + if bool(self._get_state_value("paused", False)): + if toggle: + return self.resume() + return "Уже на паузе." + self._set_state(paused=True, last_action="pause") + return "Пауза сохранена. Продолжу с прошлого места." + + raise NavidromeCommandError("Сейчас ничего не играет") + + def pause(self) -> str: + """Toggle pause: second 'pause' resumes from stored position.""" + return self._pause_impl(toggle=True) + + def pause_strict(self) -> str: + """Strict pause without auto-resume toggle.""" + return self._pause_impl(toggle=False) + + def resume(self) -> str: + self._ensure_initialized() + + if self._is_mpv_alive(): + paused = bool(self._mpv_ipc(["get_property", "pause"])) + if paused: + self._mpv_ipc(["set_property", "pause", False]) + self._set_state(paused=False, last_action="resume") + song = self._current_song() + if song: + return f"Продолжаю: {song.title} от {song.artist}" + return "Продолжаю воспроизведение" + return "Музыка уже играет" + + song = self._current_song() + if not song: + raise NavidromeCommandError("Нет сохранённого трека для продолжения") + + start_sec = float(self._get_state_value("position_sec", 0.0) or 0.0) + self._start_song(song, start_sec=start_sec) + self._set_state(paused=False, last_action="resume") + return f"Продолжаю: {song.title} от {song.artist}" + + def next_track(self) -> str: + self._ensure_initialized() + queue = self._queue() + + if not queue: + queue = self._get_random_queue(size=20) + next_index = 0 + else: + current_index = int(self._get_state_value("queue_index", 0) or 0) + next_index = (current_index + 1) % len(queue) + + song = self._play_queue_index(queue, next_index, start_sec=0.0) + self._set_state(last_action="next") + return f"Следующий трек: {song.title} от {song.artist}" + + def previous_track(self) -> str: + self._ensure_initialized() + queue = self._queue() + + if not queue: + queue = self._get_random_queue(size=20) + previous_index = 0 + else: + current_index = int(self._get_state_value("queue_index", 0) or 0) + previous_index = (current_index - 1) % len(queue) + + song = self._play_queue_index(queue, previous_index, start_sec=0.0) + self._set_state(last_action="previous") + return f"Предыдущий трек: {song.title} от {song.artist}" + + def current_track(self) -> str: + if self._is_mpv_alive(): + self._sync_position_snapshot() + song = self._current_song() + + if song is None: return "Сейчас ничего не играет" - except Exception as e: - return f"Не удалось получить информацию: {e}" + + paused = bool(self._get_state_value("paused", False)) + position_sec = int(float(self._get_state_value("position_sec", 0.0) or 0.0)) + + minutes = position_sec // 60 + seconds = position_sec % 60 + position_text = f"{minutes}:{seconds:02d}" + + status = "На паузе" if paused else "Сейчас играет" + return f"{status}: {song.title} от {song.artist}. Позиция {position_text}" + + +class MusicController: + """Voice command router for music providers.""" + + def __init__(self) -> None: + self.navidrome = NavidromeProvider() + self.spotify = SpotifyProvider() + aliases = sorted( + { + alias.lower().replace("ё", "е").strip() + for alias in WAKE_WORD_ALIASES + if alias and alias.strip() + }, + key=len, + reverse=True, + ) + if aliases: + self._wakeword_prefix_re = re.compile( + rf"^(?:{'|'.join(re.escape(alias) for alias in aliases)})(?:[\s,.:;!?-]+|$)", + flags=re.IGNORECASE, + ) + else: + self._wakeword_prefix_re = None + + def _with_fallback( + self, + nav_action: Callable[[], str], + spotify_action: Callable[[], str], + ) -> str: + try: + return nav_action() + except NavidromeCommandError as exc: + return str(exc) + except NavidromeUnavailableError as exc: + spotify_response = spotify_action() + return ( + "Navidrome недоступен, переключаюсь на Spotify. " + f"{spotify_response}" + f" (Причина: {exc})" + ) + + def pause_for_stop_word(self) -> Optional[str]: + """ + Pause music for generic stop-words ("стоп", "хватит", etc). + Returns response text only when something was really paused. + """ + try: + return self.navidrome.pause_strict() + except (NavidromeCommandError, NavidromeUnavailableError): + pass + + spotify_response = self.spotify.pause_music() + spotify_lower = spotify_response.lower() + if "пауз" in spotify_lower or "pause" in spotify_lower: + return spotify_response + return None + + def _is_pause_command(self, text: str) -> bool: + return bool( + re.search( + r"^(пауза|поставь на паузу|останови|стоп музыка|выключи музыку|pause)$", + text, + ) + ) + + def _is_resume_command(self, text: str) -> bool: + return bool( + re.search( + r"^(продолжи|продолжай|возобнови|сними с паузы|resume|continue|играй дальше)$", + text, + ) + ) + + def _is_next_command(self, text: str) -> bool: + return bool( + re.search( + r"^(следующ(ий|ая|ее)?( трек)?|дальше|скип|пропусти|next|skip)$", + text, + ) + ) + + def _is_previous_command(self, text: str) -> bool: + return bool( + re.search( + r"^(предыдущ(ий|ая|ее)?( трек)?|назад|верни( назад)?|previous|back)$", + text, + ) + ) + + def _is_current_command(self, text: str) -> bool: + return bool( + re.search( + r"^(что( сейчас)? играет|какая песня|что за (песня|трек|музыка)|what.*(playing|song)|current track)$", + text, + ) + ) + + def _normalize_command_text(self, text: str) -> str: + normalized = text.lower().replace("ё", "е").strip() + normalized = re.sub(r"\s+", " ", normalized) + if self._wakeword_prefix_re is not None: + normalized = self._wakeword_prefix_re.sub("", normalized, count=1).strip() + # STT часто добавляет завершающую пунктуацию: "включи музыку." + normalized = re.sub(r"[.!?,;:…]+$", "", normalized).strip() + return normalized + + def _sanitize_query(self, query: str) -> Optional[str]: + value = query.strip() + value = re.sub(r"\s+", " ", value) + value = re.sub(r"^[\"'`«»“”()\[\]{}<>\-–—.,!?;:…/\\\s]+", "", value) + value = re.sub(r"[\"'`«»“”()\[\]{}<>\-–—.,!?;:…/\\\s]+$", "", value) + if not value: + return None + if not re.search(r"[0-9a-zа-яё]", value, flags=re.IGNORECASE): + return None + return value + + def _extract_genre_query(self, text: str) -> Optional[str]: + patterns = [ + r"^(?:включи|поставь|играй)\s+(?:жанр|genre)\s+(.+)$", + r"^включи\s+музыку\s+жанра\s+(.+)$", + ] + for pattern in patterns: + match = re.search(pattern, text) + if match: + return self._sanitize_query(match.group(1)) + return None + + def _extract_folder_query(self, text: str) -> Optional[str]: + patterns = [ + r"^(?:включи|поставь|играй)\s+(?:из\s+)?папк(?:у|е|и|а)?\s+(.+)$", + r"^включи\s+музыку\s+из\s+папки\s+(.+)$", + ] + for pattern in patterns: + match = re.search(pattern, text) + if match: + return self._sanitize_query(match.group(1)) + return None + + def _extract_play_query(self, text: str) -> tuple[bool, Optional[str]]: + patterns = [ + r"^(?:play music|включи музыку|поставь музыку|играй музыку)[.!?,;:…]*$", + r"^(?:включи|поставь|играй|play)\s+(.+)$", + ] + + for pattern in patterns: + match = re.search(pattern, text) + if not match: + continue + + if not match.groups(): + return True, None + + query = match.group(1).strip() + query = re.sub(r"\b(музыку|песню|трек|song|track|music)\b", " ", query) + query = self._sanitize_query(query) + return True, query + + return False, None + + def handle_semantic_action(self, action: str, query: Optional[str] = None) -> Optional[str]: + normalized_action = str(action or "").strip().lower() + normalized_query = self._sanitize_query(query or "") if query else None + + if normalized_action in {"play", "play_random", "random"}: + if normalized_query: + return self._with_fallback( + 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), + ) + + if normalized_action in {"play_query", "search"}: + if not normalized_query: + return None + return self._with_fallback( + lambda: self.navidrome.play_query(normalized_query), + lambda: self.spotify.play_music(normalized_query), + ) + + if normalized_action == "play_genre": + if not normalized_query: + return "Уточни жанр, который нужно включить." + return self._with_fallback( + lambda: self.navidrome.play_genre(normalized_query), + lambda: self.spotify.play_music(normalized_query), + ) + + if normalized_action == "play_folder": + if not normalized_query: + return "Уточни папку, из которой нужно включить музыку." + return self._with_fallback( + lambda: self.navidrome.play_folder(normalized_query), + lambda: self.spotify.play_music(normalized_query), + ) + + if normalized_action in {"pause", "stop"}: + return self._with_fallback(self.navidrome.pause, self.spotify.pause_toggle) + + if normalized_action in {"resume", "continue"}: + return self._with_fallback(self.navidrome.resume, self.spotify.resume_music) + + if normalized_action in {"next", "skip"}: + return self._with_fallback(self.navidrome.next_track, self.spotify.next_track) + + if normalized_action in {"previous", "back"}: + return self._with_fallback( + self.navidrome.previous_track, + self.spotify.previous_track, + ) + + if normalized_action in {"current", "status", "what_playing"}: + return self._with_fallback( + self.navidrome.current_track, + self.spotify.get_current_track, + ) + + return None def parse_command(self, text: str) -> Optional[str]: - """ - Распознать музыкальную команду и выполнить её. - - Args: - text: Текст команды от пользователя. - - Returns: - Ответ для озвучки или None, если это не музыкальная команда. - """ - text_lower = text.lower().strip() + text_lower = self._normalize_command_text(text) + if not text_lower: + return None - # Команды паузы - pause_patterns = [ - r"^(поставь на паузу|пауза|стоп музык|останови музык|выключи музык)", - r"(pause|stop music)", - ] - for pattern in pause_patterns: - if re.search(pattern, text_lower): - return self.pause_music() + genre_query = self._extract_genre_query(text_lower) + if genre_query: + return self._with_fallback( + lambda: self.navidrome.play_genre(genre_query), + lambda: self.spotify.play_music(genre_query), + ) - # Команды продолжения - resume_patterns = [ - r"^(продолжи|продолжай|возобнови|сними с паузы|дальше|играй дальше)", - r"(resume|continue|play)", - ] - for pattern in resume_patterns: - if re.search(pattern, text_lower): - return self.resume_music() + folder_query = self._extract_folder_query(text_lower) + if folder_query: + return self._with_fallback( + lambda: self.navidrome.play_folder(folder_query), + lambda: self.spotify.play_music(folder_query), + ) - # Следующий трек - 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() + if self._is_pause_command(text_lower): + return self._with_fallback(self.navidrome.pause, self.spotify.pause_toggle) - # Предыдущий трек - 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() + if self._is_resume_command(text_lower): + return self._with_fallback(self.navidrome.resume, self.spotify.resume_music) - # Явные команды распознавания музыки (типа "угадай песню") - recognize_patterns = [ - r"((waltron|voltron|волтрон|уолтрон|валтрон)\s+)?(угадай|распознай|определи)\s+(мелод|музык|песн|трек)", - r"((waltron|voltron|волтрон|уолтрон|валтрон)\s+)?(что за|какая это)\s+(музык|песн|трек)", - r"(identify|recognize)\s+(song|music|track)", - ] - for pattern in recognize_patterns: - if re.search(pattern, text_lower): - return self.get_current_track() + if self._is_next_command(text_lower): + return self._with_fallback(self.navidrome.next_track, self.spotify.next_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() + if self._is_previous_command(text_lower): + return self._with_fallback( + self.navidrome.previous_track, + self.spotify.previous_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) + if self._is_current_command(text_lower): + return self._with_fallback( + self.navidrome.current_track, + self.spotify.get_current_track, + ) + + play_matched, play_query = self._extract_play_query(text_lower) + if play_matched: + if play_query: + return self._with_fallback( + 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 None -def get_music_controller() -> SpotifyMusicController: - """Получить singleton экземпляр контроллера музыки.""" +_music_controller: Optional[MusicController] = None + + +def get_music_controller() -> MusicController: + """Get singleton music controller.""" global _music_controller if _music_controller is None: - _music_controller = SpotifyMusicController() + _music_controller = MusicController() return _music_controller diff --git a/app/main.py b/app/main.py index b7b01ad..edde6c8 100644 --- a/app/main.py +++ b/app/main.py @@ -33,7 +33,7 @@ from .audio.wakeword import ( from .audio.wakeword import ( stop_monitoring as stop_wakeword_monitoring, ) -from .core.ai import ask_ai_stream, translate_text +from .core.ai import ask_ai_stream, interpret_assistant_intent, translate_text from .core.config import BASE_DIR, WAKE_WORD from .core.cleaner import clean_response from .core.commands import is_stop_command @@ -163,6 +163,10 @@ _CITY_PATTERNS = [ ), ] +_SEMANTIC_INTENT_MIN_CONFIDENCE = 0.55 +_SEMANTIC_MUSIC_MIN_CONFIDENCE = 0.45 +_SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE = 0.72 + def signal_handler(sig, frame): """Обработчик Ctrl+C.""" @@ -311,7 +315,7 @@ def main(): continue # Продолжаем цикл else: # Follow-up режим — без wake word - print(f"👂 Слушаю ({followup_idle_timeout_seconds:.0f} сек)...") + print(f"👂 Слушаю ({followup_idle_timeout_seconds:.1f} сек)...") try: user_text = listen( timeout_seconds=7.0, @@ -341,6 +345,11 @@ def main(): # Проверка на команду "Стоп" if is_stop_command(user_text): + music_controller = get_music_controller() + music_stop_response = music_controller.pause_for_stop_word() + if music_stop_response: + print(f"🎵 {music_stop_response}") + if stopwatch_manager.has_running_stopwatches(): stopwatch_stop_response = stopwatch_manager.pause_stopwatches() clean_stopwatch_stop_response = clean_response( @@ -369,8 +378,93 @@ def main(): skip_wakeword = True continue + effective_text = user_text + semantic_intent = interpret_assistant_intent(user_text) + semantic_type = str(semantic_intent.get("intent", "none")).strip().lower() + try: + semantic_confidence = float( + semantic_intent.get("confidence", 0.0) or 0.0 + ) + except (TypeError, ValueError): + semantic_confidence = 0.0 + semantic_command = str(semantic_intent.get("normalized_command", "")).strip() + semantic_music_action = ( + str(semantic_intent.get("music_action", "none")).strip().lower() + ) + semantic_music_query = str(semantic_intent.get("music_query", "")).strip() + + if ( + semantic_type == "stop" + and semantic_confidence >= _SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE + ): + music_controller = get_music_controller() + music_stop_response = music_controller.pause_for_stop_word() + if music_stop_response: + print(f"🎵 {music_stop_response}") + + if stopwatch_manager.has_running_stopwatches(): + stopwatch_stop_response = stopwatch_manager.pause_stopwatches() + clean_stopwatch_stop_response = clean_response( + stopwatch_stop_response, language="ru" + ) + speak(clean_stopwatch_stop_response) + last_response = clean_stopwatch_stop_response + skip_wakeword = False + continue + print("_" * 50) + print(f"💤 Жду '{WAKE_WORD}'...") + skip_wakeword = False + continue + + if ( + semantic_type == "repeat" + and semantic_confidence >= _SEMANTIC_REPEAT_STOP_MIN_CONFIDENCE + ): + if last_response: + print(f"🔁 Повторяю: {last_response}") + speak(last_response) + else: + speak("Я еще ничего не говорил.") + skip_wakeword = True + continue + + if ( + semantic_type == "music" + and semantic_confidence >= _SEMANTIC_MUSIC_MIN_CONFIDENCE + ): + music_controller = get_music_controller() + semantic_music_response = music_controller.handle_semantic_action( + semantic_music_action, + semantic_music_query, + ) + if semantic_music_response: + clean_music_response = clean_response( + semantic_music_response, language="ru" + ) + speak(clean_music_response) + last_response = clean_music_response + skip_wakeword = True + continue + + if ( + semantic_command + and semantic_confidence >= _SEMANTIC_INTENT_MIN_CONFIDENCE + and semantic_type + in { + "music", + "timer", + "alarm", + "weather", + "volume", + "translation", + "cities", + } + ): + effective_text = semantic_command + print(f"🧠 Команда: '{user_text}' -> '{effective_text}'") + # Small-talk - smalltalk_response = get_smalltalk_response(user_text) + smalltalk_response = get_smalltalk_response(effective_text) if smalltalk_response: clean_smalltalk = clean_response(smalltalk_response, language="ru") speak(clean_smalltalk) @@ -378,7 +472,7 @@ def main(): skip_wakeword = True continue - command_text = user_text + command_text = effective_text command_text_lower = command_text.lower() if pending_time_target == "timer" and "таймер" not in command_text_lower: command_text = f"таймер {command_text}" @@ -427,9 +521,9 @@ def main(): continue # Громкость - if user_text.lower().startswith("громкость"): + if command_text.lower().startswith("громкость"): try: - vol_str = user_text.lower().replace("громкость", "", 1).strip() + vol_str = command_text.lower().replace("громкость", "", 1).strip() level = parse_volume_text(vol_str) if level is not None: @@ -455,7 +549,7 @@ def main(): # Погода requested_city = None - user_text_lower = user_text.lower() + user_text_lower = command_text.lower() for pattern in _CITY_PATTERNS: match = pattern.search(user_text_lower) @@ -487,7 +581,7 @@ def main(): # Музыка music_controller = get_music_controller() - music_response = music_controller.parse_command(user_text) + music_response = music_controller.parse_command(command_text) if music_response: clean_music_response = clean_response(music_response, language="ru") speak(clean_music_response) @@ -496,7 +590,7 @@ def main(): continue # Перевод - translation_request = parse_translation_request(user_text) + translation_request = parse_translation_request(command_text) if translation_request: source_lang = translation_request["source_lang"] target_lang = translation_request["target_lang"] @@ -553,8 +647,7 @@ def main(): continue # Игра "Города" - cities_response = cities_game.handle(user_text) - cities_response = cities_game.handle(user_text) + cities_response = cities_game.handle(command_text) if cities_response: clean_cities_response = clean_response(cities_response, language="ru") speak(clean_cities_response)