Merge pull request #1 from sean351/feat/mpris2-volume-routing

Add MPRIS2 volume routing and PA event watcher; fix Spotify song transition reset
main
Sean 2026-02-27 18:30:23 -05:00 committed by GitHub
commit db3d9e34a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 253 additions and 5 deletions

View File

@ -14,9 +14,11 @@ even when no knob is being moved.
import logging import logging
import os import os
import queue
import signal import signal
import subprocess import subprocess
import sys import sys
import threading
import time import time
import pulsectl import pulsectl
@ -131,17 +133,156 @@ def all_led_colors(
] ]
# ── MPRIS2 controller (playerctl-backed) ──────────────────────────────────────
class MPRISController:
"""Uses *playerctl* to read/write per-app volume via the MPRIS2 D-Bus interface.
Caches the player list for ``_CACHE_TTL`` seconds to avoid spawning a new
subprocess on every single call.
"""
_CACHE_TTL: float = 3.0 # seconds between ``playerctl --list-all`` calls
def __init__(self) -> None:
self._players: list[str] = []
self._players_ts: float = 0.0
self._lock = threading.Lock()
# ── internal helpers ──────────────────────────────────────────────────────
def _run(self, *args: str, timeout: float = 2.0) -> tuple[bool, str]:
"""Run ``playerctl <args>`` and return ``(success, stdout.strip())``."""
try:
result = subprocess.run(
["playerctl", *args],
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode == 0, result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
log.debug("playerctl call failed: %s", exc)
return False, ""
def _refresh_players(self, *, force: bool = False) -> None:
"""Refresh the cached player list if it has expired (or *force* is set)."""
now = time.monotonic()
if not force and (now - self._players_ts) < self._CACHE_TTL:
return
ok, out = self._run("--list-all")
with self._lock:
self._players = [p.strip() for p in out.splitlines() if p.strip()] if ok else []
self._players_ts = now
# ── public API ────────────────────────────────────────────────────────────
def find_player(self, app_name: str) -> str | None:
"""Return the first cached player whose name contains *app_name* (case-insensitive)."""
self._refresh_players()
needle = app_name.lower()
with self._lock:
for player in self._players:
if needle in player.lower():
return player
return None
def get_volume(self, app_name: str) -> float | None:
"""Return the MPRIS volume (0.01.0) for *app_name*, or ``None`` if unavailable."""
player = self.find_player(app_name)
if player is None:
return None
ok, out = self._run("--player", player, "volume")
if not ok or not out:
return None
try:
return max(0.0, min(1.0, float(out)))
except ValueError:
return None
def set_volume(self, app_name: str, volume: float) -> bool:
"""Set the MPRIS volume for *app_name*. Returns ``True`` on success."""
player = self.find_player(app_name)
if player is None:
return False
volume = max(0.0, min(1.0, volume))
ok, _ = self._run("--player", player, "volume", f"{volume:.4f}")
if ok:
log.debug("MPRIS: %r volume → %.4f", player, volume)
return ok
# ── PulseAudio / PipeWire controller ────────────────────────────────────────── # ── PulseAudio / PipeWire controller ──────────────────────────────────────────
class PulseController: class PulseController:
"""Thin wrapper around :class:`pulsectl.Pulse` for volume and mute control.""" """Thin wrapper around :class:`pulsectl.Pulse` for volume and mute control.
def __init__(self) -> None: When *mpris* is supplied, ``set_app_volume`` and ``get_app_volume_norm``
will prefer the MPRIS2 path for any app that has a live playerctl player,
falling back to the PulseAudio stream only when MPRIS is unavailable.
A background watcher thread (started by :meth:`start_watching`) listens
for PulseAudio sink-input events and pushes indices onto ``_event_q`` so
the main loop can trigger an immediate reapply for PA-only apps instead of
waiting for the 1-second timer.
"""
def __init__(self, mpris: MPRISController | None = None) -> None:
self._pulse = pulsectl.Pulse("turnupd") self._pulse = pulsectl.Pulse("turnupd")
self._mpris = mpris
self._event_q: queue.Queue[int] = queue.Queue()
self._watcher_thread: threading.Thread | None = None
self._stop_event = threading.Event()
def close(self) -> None: def close(self) -> None:
self._stop_event.set()
self._pulse.close() self._pulse.close()
# ── PA event watcher ──────────────────────────────────────────────────────
def start_watching(self) -> None:
"""Spawn the background PA event listener thread (idempotent)."""
if self._watcher_thread and self._watcher_thread.is_alive():
return
self._stop_event.clear()
t = threading.Thread(target=self._event_loop, daemon=True, name="pa-watcher")
t.start()
self._watcher_thread = t
log.debug("PA watcher thread started")
def _event_loop(self) -> None:
"""Background thread: open a *separate* Pulse connection and listen for events."""
try:
with pulsectl.Pulse("turnupd-watcher") as watch_pulse:
def _cb(ev: pulsectl.PulseEventInfo) -> None: # type: ignore[name-defined]
if ev.facility == "sink_input":
self._event_q.put(int(ev.index))
raise pulsectl.PulseLoopStop
watch_pulse.event_mask_set("sink_input")
watch_pulse.event_callback_set(_cb)
while not self._stop_event.is_set():
try:
watch_pulse.event_listen(timeout=1.0)
except pulsectl.PulseLoopStop:
pass
except Exception as exc:
log.debug("PA event loop error: %s", exc)
time.sleep(0.5)
except Exception as exc:
log.warning("PA watcher thread exiting: %s", exc)
def drain_events(self) -> bool:
"""Drain all pending PA events. Returns ``True`` if any events were present."""
had_events = False
while True:
try:
self._event_q.get_nowait()
had_events = True
except queue.Empty:
break
return had_events
def set_sink_volume(self, sink_name: str, volume: float) -> None: def set_sink_volume(self, sink_name: str, volume: float) -> None:
volume = max(0.0, min(VOLUME_MAX, volume)) volume = max(0.0, min(VOLUME_MAX, volume))
try: try:
@ -192,15 +333,25 @@ class PulseController:
def set_app_volume(self, app_name: str, volume: float) -> None: def set_app_volume(self, app_name: str, volume: float) -> None:
volume = max(0.0, min(VOLUME_MAX, volume)) volume = max(0.0, min(VOLUME_MAX, volume))
# Prefer the MPRIS2 path — it writes to the app's internal slider so the
# volume survives song transitions (e.g. Spotify resetting on new tracks).
if self._mpris and self._mpris.set_volume(app_name, volume):
log.debug("MPRIS set_volume: %r = %.4f", app_name, volume)
return
# Fall back to PulseAudio stream volume.
needle = app_name.lower() needle = app_name.lower()
found = False
try: try:
for inp in self._pulse.sink_input_list(): for inp in self._pulse.sink_input_list():
name = inp.proplist.get("application.name", "") name = inp.proplist.get("application.name", "")
binary = inp.proplist.get("application.process.binary", "") binary = inp.proplist.get("application.process.binary", "")
if needle in name.lower() or needle in binary.lower(): if needle in name.lower() or needle in binary.lower():
self._pulse.volume_set_all_chans(inp, volume) self._pulse.volume_set_all_chans(inp, volume)
return found = True
log.debug("App %r not found in sink inputs", app_name) if not found:
log.debug("App %r not found in sink inputs", app_name)
except Exception as exc: except Exception as exc:
log.warning("set_app_volume(%r) failed: %s", app_name, exc) log.warning("set_app_volume(%r) failed: %s", app_name, exc)
@ -230,6 +381,13 @@ class PulseController:
def get_app_volume_norm(self, app_name: str) -> float | None: def get_app_volume_norm(self, app_name: str) -> float | None:
"""Return the current app volume normalised to 0.01.0, or None if not found.""" """Return the current app volume normalised to 0.01.0, or None if not found."""
# Prefer MPRIS — more accurate for apps like Spotify.
if self._mpris:
vol = self._mpris.get_volume(app_name)
if vol is not None:
return vol
# Fall back to PulseAudio stream.
needle = app_name.lower() needle = app_name.lower()
try: try:
for inp in self._pulse.sink_input_list(): for inp in self._pulse.sink_input_list():
@ -278,6 +436,83 @@ def init_knob_norms(config: dict, pulse: PulseController) -> list[float]:
return norms return norms
def build_app_volume_map(config: dict, knob_norms: list[float]) -> dict[str, float]:
"""Return ``{app_name_lower: volume}`` for every app/group knob in *config*.
Used by :func:`reapply_app_volumes` to know what volume each configured
application should currently be at, based on the last knob positions.
"""
app_volumes: dict[str, float] = {}
for knob_id_str, knob_cfg in config.get("knobs", {}).items():
try:
knob_id = int(knob_id_str)
except ValueError:
continue
if knob_id >= NUM_KNOBS:
continue
action = knob_cfg.get("action", "")
vol = round(knob_norms[knob_id] * VOLUME_MAX, 4)
if action == "app_volume":
t = knob_cfg.get("target", "")
if t:
app_volumes[t.lower()] = vol
elif action == "group_volume":
for t in knob_cfg.get("targets", []):
if t:
app_volumes[t.lower()] = vol
return app_volumes
def reapply_app_volumes(config: dict, pulse: PulseController, knob_norms: list[float]) -> None:
"""Re-apply stored knob volumes to every matching active sink input.
Called on a 1-second timer and whenever a PA sink-input event fires so
that new streams (e.g. Spotify starting a new song) are brought back to
the last knob position rather than being left at the 100 % default that
``module-stream-restore`` restores them to.
For MPRIS-capable apps the volume is written via playerctl (which updates
the app's own internal slider). For PA-only apps (e.g. Brave) the volume
is corrected on the PulseAudio stream level.
"""
app_volumes = build_app_volume_map(config, knob_norms)
if not app_volumes:
return
# Split targets into MPRIS-handled vs PA-only.
mpris = pulse._mpris
pa_only: dict[str, float] = {}
for app_name, vol in app_volumes.items():
if mpris and mpris.set_volume(app_name, vol):
log.debug("reapply MPRIS: %r%.4f", app_name, vol)
else:
pa_only[app_name] = vol
if not pa_only:
return
# PA stream correction for non-MPRIS apps.
try:
for inp in pulse._pulse.sink_input_list():
name = inp.proplist.get("application.name", "").lower()
binary = inp.proplist.get("application.process.binary", "").lower()
for needle, vol in pa_only.items():
if needle in name or needle in binary:
current = inp.volume.value_flat
if abs(current - vol) > 0.01:
pulse._pulse.volume_set_all_chans(inp, vol)
log.debug(
"reapply PA: %r volume %.2f%.2f",
inp.proplist.get("application.name", needle),
current,
vol,
)
break # one needle is enough per stream
except Exception as exc:
log.debug("reapply_app_volumes (PA) failed: %s", exc)
# ── Event handlers ───────────────────────────────────────────────────────────── # ── Event handlers ─────────────────────────────────────────────────────────────
def handle_knob( def handle_knob(
@ -356,7 +591,9 @@ def main() -> None:
log.info("Turn Up daemon starting — %s @ %d baud", port, baud) log.info("Turn Up daemon starting — %s @ %d baud", port, baud)
pulse = PulseController() mpris = MPRISController()
pulse = PulseController(mpris)
pulse.start_watching()
knob_norms = init_knob_norms(config, pulse) knob_norms = init_knob_norms(config, pulse)
buf = bytearray() buf = bytearray()
@ -366,6 +603,9 @@ def main() -> None:
except OSError: except OSError:
config_mtime = None config_mtime = None
last_config_check = time.monotonic() last_config_check = time.monotonic()
# Re-apply app volumes periodically so new streams (e.g. Spotify new song)
# are brought to the last knob position rather than resetting to 100 %.
last_reapply = time.monotonic()
def _shutdown(sig: int, _frame: object) -> None: def _shutdown(sig: int, _frame: object) -> None:
log.info("Received signal %d — shutting down", sig) log.info("Received signal %d — shutting down", sig)
@ -413,6 +653,14 @@ def main() -> None:
except OSError: except OSError:
pass pass
# Re-apply configured app volumes every 1 s to catch new
# streams (e.g. Spotify starting a new song resets to 100 %).
# Also trigger immediately on any PA sink-input event so
# PA-only apps (e.g. Brave) are corrected within ~0.1 s.
if pulse.drain_events() or now - last_reapply >= 1.0:
last_reapply = now
reapply_app_volumes(config, pulse, knob_norms)
except serial.SerialException as exc: except serial.SerialException as exc:
log.warning("Serial error: %s — retrying in 3 s", exc) log.warning("Serial error: %s — retrying in 3 s", exc)
time.sleep(3) time.sleep(3)