feat: refine assistant logic and update docs

This commit is contained in:
future
2026-04-09 21:03:02 +03:00
parent ebe79c3692
commit 42c064a274
19 changed files with 1958 additions and 492 deletions

View File

@@ -20,7 +20,7 @@ from urllib.parse import urlencode
import requests
from ..core.config import BASE_DIR, WAKE_WORD_ALIASES
from ..core.config import BASE_DIR, WAKE_WORD_ALIASES, WAKEWORD_MUSIC_DUCK_RATIO
try:
import spotipy
@@ -97,6 +97,8 @@ class SpotifyProvider:
self.sp = None
self._initialized = False
self._init_error = None
self._duck_prev_volume: Optional[int] = None
self._duck_active = False
def initialize(self) -> bool:
if self._initialized:
@@ -249,6 +251,71 @@ class SpotifyProvider:
except Exception as exc:
return f"Spotify: {exc}"
def duck_volume(self, ratio: float) -> bool:
if self._duck_active:
return True
if not self._ensure_initialized():
return False
try:
current = self.sp.current_playback()
except Exception:
return False
if not current or not current.get("is_playing"):
return False
device = current.get("device") or {}
device_id = device.get("id")
volume_percent = device.get("volume_percent")
if volume_percent is None:
return False
try:
current_volume = int(volume_percent)
except Exception:
return False
target_volume = int(round(current_volume * float(ratio)))
target_volume = max(0, min(100, target_volume))
if target_volume >= current_volume:
target_volume = max(0, current_volume - 1)
try:
self.sp.volume(target_volume, device_id=device_id)
except Exception:
return False
self._duck_prev_volume = current_volume
self._duck_active = True
return True
def restore_duck_volume(self) -> bool:
if not self._duck_active:
return False
previous_volume = self._duck_prev_volume
self._duck_prev_volume = None
self._duck_active = False
if previous_volume is None:
return False
if not self._ensure_initialized():
return False
try:
current = self.sp.current_playback() or {}
device = current.get("device") or {}
device_id = device.get("id")
self.sp.volume(int(max(0, min(100, previous_volume))), device_id=device_id)
return True
except Exception:
return False
def clear_duck_state(self) -> None:
self._duck_prev_volume = None
self._duck_active = False
class NavidromeProvider:
"""Primary provider using Navidrome + MPV IPC."""
@@ -269,6 +336,8 @@ class NavidromeProvider:
self._folder_index_built_at = 0.0
self._autonext_lock = threading.Lock()
self._autonext_suppress_until = 0.0
self._duck_prev_volume: Optional[float] = None
self._duck_active = False
self._snapshot_stop = threading.Event()
self._snapshot_thread = threading.Thread(
@@ -764,6 +833,53 @@ class NavidromeProvider:
self._set_state(paused=paused, position_sec=max(0.0, position))
def duck_volume(self, ratio: float) -> bool:
if self._duck_active:
return True
if not self._is_mpv_alive():
return False
try:
paused = bool(self._mpv_ipc(["get_property", "pause"]))
if paused:
return False
current_volume = float(self._mpv_ipc(["get_property", "volume"]) or 100.0)
target_volume = max(0.0, min(100.0, current_volume * float(ratio)))
if target_volume >= current_volume:
target_volume = max(0.0, current_volume - 1.0)
self._mpv_ipc(["set_property", "volume", target_volume])
except Exception:
return False
self._duck_prev_volume = current_volume
self._duck_active = True
return True
def restore_duck_volume(self) -> bool:
if not self._duck_active:
return False
previous_volume = self._duck_prev_volume
self._duck_prev_volume = None
self._duck_active = False
if previous_volume is None:
return False
if not self._is_mpv_alive():
return False
try:
self._mpv_ipc(
["set_property", "volume", max(0.0, min(100.0, float(previous_volume)))]
)
return True
except Exception:
return False
def clear_duck_state(self) -> None:
self._duck_prev_volume = None
self._duck_active = False
def _snapshot_loop(self) -> None:
while not self._snapshot_stop.wait(1.0):
try:
@@ -793,6 +909,7 @@ class NavidromeProvider:
self._mpv_process = None
self._remove_stale_socket()
self.clear_duck_state()
def _start_song(self, song: Song, start_sec: float = 0.0) -> None:
self._ensure_initialized()
@@ -1024,6 +1141,8 @@ class MusicController:
def __init__(self) -> None:
self.navidrome = NavidromeProvider()
self.spotify = SpotifyProvider()
self._wakeword_duck_ratio = max(0.01, min(1.0, float(WAKEWORD_MUSIC_DUCK_RATIO)))
self._duck_active_provider: Optional[str] = None
aliases = sorted(
{
alias.lower().replace("ё", "е").strip()
@@ -1058,6 +1177,41 @@ class MusicController:
f" (Причина: {exc})"
)
def _play_default(self) -> str:
"""Default voice command ("включи музыку") with provider fallback."""
return self._with_fallback(
lambda: self.navidrome.play_random(contextual_resume=True),
lambda: self.spotify.play_music(),
)
def duck_for_wakeword(self) -> bool:
if self._duck_active_provider in {"navidrome", "spotify"}:
return True
if self._wakeword_duck_ratio >= 1.0:
return False
if self.navidrome.duck_volume(self._wakeword_duck_ratio):
self._duck_active_provider = "navidrome"
return True
if self.spotify.duck_volume(self._wakeword_duck_ratio):
self._duck_active_provider = "spotify"
return True
return False
def restore_after_wakeword(self) -> bool:
provider = self._duck_active_provider
self._duck_active_provider = None
if provider == "navidrome":
return self.navidrome.restore_duck_volume()
if provider == "spotify":
return self.spotify.restore_duck_volume()
# Защитная очистка на случай рассинхронизации состояния.
self.navidrome.clear_duck_state()
self.spotify.clear_duck_state()
return False
def pause_for_stop_word(self) -> Optional[str]:
"""
Pause music for generic stop-words ("стоп", "хватит", etc).
@@ -1187,10 +1341,7 @@ class MusicController:
lambda: self.navidrome.play_query(normalized_query),
lambda: self.spotify.play_music(normalized_query),
)
return self._with_fallback(
lambda: self.navidrome.play_random(contextual_resume=True),
lambda: self.spotify.play_music(None),
)
return self._play_default()
if normalized_action in {"play_query", "search"}:
if not normalized_query:
@@ -1286,10 +1437,7 @@ class MusicController:
lambda: self.navidrome.play_query(play_query),
lambda: self.spotify.play_music(play_query),
)
return self._with_fallback(
lambda: self.navidrome.play_random(contextual_resume=True),
lambda: self.spotify.play_music(None),
)
return self._play_default()
return None