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
Sean Doran 2026-03-20 19:45:11 -04:00
parent 3f830b4382
commit 3c0c9adc83
No known key found for this signature in database
GPG Key ID: A9D7D25CD95E8579
5 changed files with 40 additions and 22 deletions

View File

@ -1,7 +1,7 @@
# Maintainer: Sean Doran <sdoran35@gmail.com> # Maintainer: Sean Doran <sdoran35@gmail.com>
# AUR updates are automated via GitHub Actions on version tag push # AUR updates are automated via GitHub Actions on version tag push
pkgname=turn-up-arch pkgname=turn-up-arch
pkgver=2.1.0 pkgver=2.2.2
pkgrel=1 pkgrel=1
pkgdesc="USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux" pkgdesc="USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux"
arch=('any') arch=('any')

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "turnup" name = "turnup"
version = "2.2.1" version = "2.2.2"
description = "USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux" description = "USB serial knob/button mixer daemon for PipeWire/PulseAudio on Linux"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }

View File

@ -236,13 +236,17 @@ 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 # 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). # volume survives song transitions (e.g. Spotify resetting on new tracks).
if self._mpris and self._mpris.set_volume(app_name, volume): # 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) log.debug("MPRIS set_volume: %r = %.4f", app_name, volume)
return
# Fall back to PulseAudio stream volume. # Apply PulseAudio stream volume (always, not just as MPRIS fallback).
needle = app_name.lower() needle = app_name.lower()
found = False found = False
try: try:

View File

@ -224,25 +224,23 @@ def reapply_app_volumes(config: dict, pulse: PulseController, knob_norms: list[f
if not app_volumes: if not app_volumes:
return 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 mpris = pulse._mpris
pa_only: dict[str, float] = {} if mpris:
for app_name, vol in app_volumes.items(): for app_name, vol in app_volumes.items():
if mpris and mpris.set_volume(app_name, vol): if mpris.set_volume(app_name, vol):
log.debug("reapply MPRIS: %r%.4f", app_name, vol) log.debug("reapply MPRIS: %r%.4f", app_name, vol)
else:
pa_only[app_name] = vol
if not pa_only: # PA stream correction — always applied for all configured targets so that
return # PA-only apps (Brave, Discord, Electron) are not silently skipped.
# PA stream correction for non-MPRIS apps.
try: try:
for inp in pulse._pulse.sink_input_list(): for inp in pulse._pulse.sink_input_list():
name = inp.proplist.get("application.name", "").lower() name = inp.proplist.get("application.name", "").lower()
binary = inp.proplist.get("application.process.binary", "").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: if needle in name or needle in binary:
current = inp.volume.value_flat current = inp.volume.value_flat
if abs(current - vol) > 0.01: if abs(current - vol) > 0.01:

View File

@ -176,11 +176,27 @@ class TestPulseControllerSetAppVolume:
mpris.set_volume.return_value = True mpris.set_volume.return_value = True
pulse = PulseController(mpris=mpris) pulse = PulseController(mpris=mpris)
pulse._pulse.sink_input_list.return_value = []
pulse.set_app_volume("spotify", 0.6) pulse.set_app_volume("spotify", 0.6)
mpris.set_volume.assert_called_once_with("spotify", pytest.approx(0.6)) mpris.set_volume.assert_called_once_with("spotify", pytest.approx(0.6))
# PA stream should NOT be touched. # PA stream is always checked, even when MPRIS succeeds.
pulse._pulse.sink_input_list.assert_not_called() 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): def test_falls_back_to_pa_when_mpris_fails(self, mock_pulse_lib):
mpris = MagicMock(spec=MPRISController) mpris = MagicMock(spec=MPRISController)