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>
|
# 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')
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue