fix: always apply PA stream volume for app/group knobs
MPRIS set_volume returning True was causing an early return that silently skipped the PulseAudio stream write. PA-only apps like Brave and Vesktop (registered as Chromium/electron) never had their volume updated when any MPRIS player coincidentally matched the target name by substring. Both set_app_volume and reapply_app_volumes now always apply the PA correction regardless of MPRIS result, so PA-only and MPRIS-capable apps are handled correctly in all cases.main
parent
3f830b4382
commit
3c0c9adc83
2
PKGBUILD
2
PKGBUILD
|
|
@ -1,7 +1,7 @@
|
|||
# Maintainer: Sean Doran <sdoran35@gmail.com>
|
||||
# AUR updates are automated via GitHub Actions on version tag push
|
||||
pkgname=turn-up-arch
|
||||
pkgver=2.1.0
|
||||
pkgver=2.2.2
|
||||
pkgrel=1
|
||||
pkgdesc="USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux"
|
||||
arch=('any')
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "turnup"
|
||||
version = "2.2.1"
|
||||
version = "2.2.2"
|
||||
description = "USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
|
|
|||
|
|
@ -236,13 +236,17 @@ class PulseController:
|
|||
def set_app_volume(self, app_name: str, volume: float) -> None:
|
||||
volume = max(0.0, min(VOLUME_MAX, volume))
|
||||
|
||||
# Prefer the MPRIS2 path — it writes to the app's internal slider so the
|
||||
# Try 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
|
||||
# We do NOT return early on success: PA-only apps (Brave, Discord, Electron)
|
||||
# may coincidentally have an MPRIS player whose name matches the needle, but
|
||||
# their actual output volume lives on the PA stream. Always apply the PA
|
||||
# correction so both MPRIS-capable and PA-only apps are handled correctly.
|
||||
if self._mpris:
|
||||
if self._mpris.set_volume(app_name, volume):
|
||||
log.debug("MPRIS set_volume: %r = %.4f", app_name, volume)
|
||||
|
||||
# Fall back to PulseAudio stream volume.
|
||||
# Apply PulseAudio stream volume (always, not just as MPRIS fallback).
|
||||
needle = app_name.lower()
|
||||
found = False
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -224,25 +224,23 @@ def reapply_app_volumes(config: dict, pulse: PulseController, knob_norms: list[f
|
|||
if not app_volumes:
|
||||
return
|
||||
|
||||
# Split targets into MPRIS-handled vs PA-only.
|
||||
# Apply MPRIS volume for apps that support it (e.g. Spotify — persists across
|
||||
# song transitions). Do NOT skip the PA pass for apps where MPRIS succeeds:
|
||||
# PA-only apps (Brave, Discord, Electron) may coincidentally match an MPRIS
|
||||
# player by substring, but their actual output volume lives on the PA stream.
|
||||
mpris = pulse._mpris
|
||||
pa_only: dict[str, float] = {}
|
||||
if mpris:
|
||||
for app_name, vol in app_volumes.items():
|
||||
if mpris.set_volume(app_name, vol):
|
||||
log.debug("reapply MPRIS: %r → %.4f", app_name, vol)
|
||||
|
||||
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.
|
||||
# PA stream correction — always applied for all configured targets so that
|
||||
# PA-only apps (Brave, Discord, Electron) are not silently skipped.
|
||||
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():
|
||||
for needle, vol in app_volumes.items():
|
||||
if needle in name or needle in binary:
|
||||
current = inp.volume.value_flat
|
||||
if abs(current - vol) > 0.01:
|
||||
|
|
|
|||
|
|
@ -176,11 +176,27 @@ class TestPulseControllerSetAppVolume:
|
|||
mpris.set_volume.return_value = True
|
||||
|
||||
pulse = PulseController(mpris=mpris)
|
||||
pulse._pulse.sink_input_list.return_value = []
|
||||
pulse.set_app_volume("spotify", 0.6)
|
||||
|
||||
mpris.set_volume.assert_called_once_with("spotify", pytest.approx(0.6))
|
||||
# PA stream should NOT be touched.
|
||||
pulse._pulse.sink_input_list.assert_not_called()
|
||||
# PA stream is always checked, even when MPRIS succeeds.
|
||||
pulse._pulse.sink_input_list.assert_called_once()
|
||||
|
||||
def test_pa_applied_even_when_mpris_succeeds(self, mock_pulse_lib):
|
||||
"""PA-only apps (Brave, Discord) must have PA volume set even when a
|
||||
coincidentally-matching MPRIS player returns True from set_volume."""
|
||||
mpris = MagicMock(spec=MPRISController)
|
||||
mpris.set_volume.return_value = True # MPRIS claims success (false positive)
|
||||
|
||||
inp = _make_sink_input("Brave", "brave", 1.0)
|
||||
pulse = PulseController(mpris=mpris)
|
||||
pulse._pulse.sink_input_list.return_value = [inp]
|
||||
|
||||
pulse.set_app_volume("brave", 0.4)
|
||||
|
||||
# PA write must happen regardless of MPRIS success.
|
||||
pulse._pulse.volume_set_all_chans.assert_called_once_with(inp, pytest.approx(0.4))
|
||||
|
||||
def test_falls_back_to_pa_when_mpris_fails(self, mock_pulse_lib):
|
||||
mpris = MagicMock(spec=MPRISController)
|
||||
|
|
|
|||
Loading…
Reference in New Issue