feat: refine assistant logic and update docs
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user