diff --git a/PKGBUILD b/PKGBUILD index 7a9f404..5b28663 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Sean Doran # 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') diff --git a/pyproject.toml b/pyproject.toml index ea59e81..76a360f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/turnup/audio.py b/src/turnup/audio.py index 4f615c7..b7b5390 100644 --- a/src/turnup/audio.py +++ b/src/turnup/audio.py @@ -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: diff --git a/src/turnup/turnupd.py b/src/turnup/turnupd.py index 9520c93..3fbe48a 100755 --- a/src/turnup/turnupd.py +++ b/src/turnup/turnupd.py @@ -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: diff --git a/tests/test_audio.py b/tests/test_audio.py index f1d73fb..7e984f1 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -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)