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>
# 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')

View File

@ -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" }

View File

@ -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:

View File

@ -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:

View File

@ -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)