Skip to content

fix: follow volume changes made outside the app, such as Plasma's per-app slider - #577

Merged
Tiaxi merged 3 commits into
mainfrom
fix/follow-outside-stream-volume
Nov 10, 2026
Merged

Tiaxi merged 3 commits into
mainfrom
fix/follow-outside-stream-volume

Conversation

@Tiaxi

@Tiaxi Tiaxi commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #575.

What was wrong

On Plasma, autoaudiosink picks pulsesink, which implements GstStreamVolume. So playsink hands Ferrous's volume to the sound server as the stream volume (#557 / #572), and Plasma's per-app slider and Audio Volume applet set that same stream volume and mute. Ferrous only wrote it: nothing listened to the sink's notify::volume / notify::mute. A change made in Plasma never reached Ferrous's slider, its saved setting or MPRIS Volume. The next fade, track change or restart also wrote Ferrous's old level back over it.

The fix

Engine (src/playback/stream_volume.rs, backend_gst.rs)

  • Listening. StreamVolumeWatch connects notify::volume and notify::mute on every GstStreamVolume element inside the output bin: those it has now, and those it adds later through deep-element-added, since autoaudiosink makes a new pulsesink for each run. These are the same sinks that playsink and fix: silence a resume that was muted while paused #572's output_applies_volume() find. Outputs without one (alsasink, fakesink, pipewiresink or other FERROUS_GST_OUTPUT_SINK overrides that don't control stream volume) get no handler and behave as before.
  • Thread safety. The handler runs on the thread that notifies, which for pulsesink is its streaming thread. It reads the sink's volume and mute there (pulsesink's read asks the server) and sends a report over a channel. The engine loop select!s on that channel and changes engine state only on its own thread. The handler never touches playbin or playsink, whose lock a state change can hold while it waits for a streaming thread. The only lock the handler takes serializes reads, so reports arrive in the order they were read.
  • Telling the engine's own writes apart (sequence and value, no timers). Every write the engine makes to the stream (the user's volume, fades, silenced phases, and fix: silence a resume that was muted while paused #572's re-set at Ready/Null) goes through one helper, which numbers it and records the gain and mute it leaves behind.
    • Notifications that come from the engine's own set_property on its own thread are skipped at the source (a thread-local set during the write).
    • A report carries the number of writes that had begun when its read returned. If that is older than the engine's latest write, the report is ignored: that write is newer on the server, and a change made after it gets notified again.
    • Otherwise the value counts as the engine's own if it matches any of its writes from the latest one the stream has shown onward. This was found on the real server: pipewire-pulse applies a write some time after it acknowledges it, so a read right after a fade step returns an earlier step. My first version, which compared only against the writes that could have completed, read those steps as outside changes and stopped fades partway (see the table below). The server applies writes in order, so the value it reports only moves forward through the engine's writes. A value outside that window was set by someone else.
    • Sinks below Paused are not read. They have no stream yet, so nothing outside can have changed them. Their notifications are playsink setting up the new run, which pushes the volume and then the mute. A read between the two would see a state the engine never set (a test caught this).
  • Taking the change in. An outside change becomes the user's volume, as if set in the app. The stream already plays at it, so it applies at once and ends any fade in progress. A run that is silenced until it plays, or that holds the AC-3/DTS start silence, stays silent and comes up to the new level when it plays. The engine publishes the level in its snapshot and sends a new PlaybackEvent::OutsideVolume { level, set_volume_commands }.
  • New runs keep the outside level. The target volume is now the outside level, so fix: silence a resume that was muted while paused #572's re-set at Ready/Null, begin_track_start and the startup fade all bring each new run's sink to it.
  • Scale. pulsesink's volume is linear. It is converted with gst_stream_volume_convert_volume(LINEAR → CUBIC), the same cubic scale as Ferrous's volume_cubic and Plasma's percentage. 66 % round-trips exactly (unit test), and pactl … 66% reads as 0.66 on the real server (pactl's own rounding gives 0.65999, which shows as 66 %). Levels above 100 % (Plasma's raised maximum) are clamped to Ferrous's 100 %. The stream keeps the raised level until the engine next writes its volume.

Outside mute: shown as volume 0, the stream keeps its level

Ferrous has no separate mute state. Its mute button sets the volume to 0, and QML's rememberedVolumeBeforeMute restores the level. So an outside mute is shown the same way:

  • Ferrous's slider, setting and MPRIS Volume read 0. The mute button shows muted, and QML remembers the level from before.
  • The engine leaves the stream's own volume alone and keeps the server's mute flag. An unmute in Plasma brings the level straight back in both apps.
  • The mute carries over to the next run's stream: the engine also writes mute at Ready/Null while it is muted, so playsink starts the new stream muted, at the level.
  • Any volume set in Ferrous (its unmute button, the slider, MPRIS Volume) unmutes the stream and plays at that level. So Ferrous's unmute restores the remembered level and is heard.

I picked this over turning an outside mute into "unmuted at volume 0". That would make Plasma's mute toggle snap back to unmuted at 0 %.

Trade-off: while muted outside, the saved volume is 0, the same as with Ferrous's own mute button. After a restart of the app the level from before the mute is gone.

Bridge (src/frontend_bridge)

  • Every SetVolume now goes through commands::command_volume, which counts them (BridgeState::volume_commands_sent).
  • An OutsideVolume event is taken only if the engine had handled all of the bridge's SetVolume commands by then. If the user moved the slider in the meantime, that newer command wins in the engine too, so the stale outside level is dropped. This is the stale-result check.
  • A taken event sets settings.volume, which is what the slider and MPRIS read, and marks the settings for saving. This works on both paths where playback events reach the bridge. It also writes one diagnostics-log line: "Volume changed outside the app: 66%." No UI notice.
  • The FFI protocol is unchanged. The settings section already carries the volume to BridgeClient::volume(), TransportBar's slider and MprisController::volume().

Verified on a private PipeWire server

The setup: pipewire, pipewire-pulse and WirePlumber under dbus-run-session, with temporary XDG and runtime directories, every hardware monitor disabled, support.dbus = false and one null sink. A guard checked that pactl info named the private socket before anything ran. A scratch test (not committed) drove the real PlaybackEngine with the default output (autoaudiosink → pulsesink) and used pactl on Ferrous's sink input. It never touched the user's server or devices.

Step Before the window fix This branch
set-sink-input-volume 66% while playing engine 0.65999, stream stays there same
App SetVolume(0.3): a fade on the stream two fade steps taken as outside changes; fade stopped at 47 % no outside event, stream at 30 %
50% from outside 0.5 0.5
Restart (seek after the successor was handed over), new sink input fade-in taken as outside, stuck at 1 % new stream at 50 %, no event
set-sink-input-mute 1 engine 0, stream muted at 50 % same
Next track, new sink input muted, 50 % muted, 50 %, no event
set-sink-input-mute 0 engine 0.5 engine 0.5
Mute outside, then app SetVolume(0.7) fade steps taken as outside, 11 % stream unmuted at 70 %
40% while paused, then Play taken on resume taken on resume, stream stays at 40 %

Known limitation: pulsesink notifies only from its streaming thread as it renders. So a change made in Plasma while Ferrous is paused reaches Ferrous when playback resumes, not while it is paused. The resume doesn't overwrite it: a mid-track resume writes no volume, and the first rendered buffer reports the change.

Tests

On the fakeaudiosink harness from #572 (volume_applying_output, with a new sink for every run):

  • a_volume_set_on_the_stream_elsewhere_becomes_the_users_volume_and_stays: Plasma's 66 % reads as 0.66 in the snapshot and the event, and no fade takes the stream back. A restart (new sink, checked by identity) and then a track change (another new sink) both play at 0.66, with no further events.
  • a_mute_set_on_the_stream_elsewhere_shows_as_volume_0_and_keeps_the_volume: an outside mute shows as 0 and the stream keeps its level. The next track's new stream starts muted at that level. An outside unmute brings back 0.8. A volume set in the app unmutes the stream.
  • the_engines_own_volume_changes_are_never_taken_for_changes_made_elsewhere: a fade while playing, changes while paused, resume, seek, gapless switch, Repeat-One replay, track change, stop and start. Every sink change is also re-notified from another thread, as pulsesink does after the server's change event. No OutsideVolume comes out.
  • a_volume_set_elsewhere_while_a_run_is_silenced_waits_until_it_plays (DirectEngine): a track picked while paused gets an outside level. The stream goes back to silence, and Play then plays at once at the new level.
  • stream_volume unit tests: the exact 66 % cubic/linear round trip. Fade steps read back late are no change. A value the stream has already moved past is an outside change. A read before the latest write tells nothing. The engine's own notifications on its thread are not read, and neither are sinks below Paused.
  • Bridge, a_volume_set_outside_the_app_is_shown_and_saved_unless_the_app_set_one_since: the event updates and saves the setting and reaches the snapshot. One older than the app's latest SetVolume is ignored. It also works through the drain path.
  • Qt, volumeTheBackendSendsReachesTheSliderAndMpris: a settings volume from the backend reaches BridgeClient::volume() (the slider's binding) and MprisController::volume(), including 0 for an outside mute.

Regression check: with the mute write at Ready/Null removed, the mute test fails ("the next track's stream stays muted"). With the Paused filter removed, it fails on playsink's volume-then-mute push. The lag case is covered by the unit test and the private-server run. fakeaudiosink reads back without delay.

Validation

TMPDIR=$HOME/.cache/ferrous-ui373/tmp-575 ./scripts/run-tests.sh (Rust and UI) passes:

  • Rust: fmt, check, strict Clippy (pedantic), and cargo-audit pass. 1815 tests pass, 5 are ignored as before.
  • UI: CTest runs 104 of 104 tests, all passing.

The private PipeWire check above was run by hand on Fedora 44 (GStreamer 1.28.9, PipeWire 1.6.11). CI can't run it.

Review round 1

1. Repeated values left old writes counted as echoes. classify matched the first write with the reported value. So with history [80 %, 0 %, 80 %], written while paused with no read in between, the 80 % read on resume left 0 % eligible, and a 0 % set in Plasma was swallowed as an echo. It now takes the latest write with the value (rposition) and drops every write before it. The tolerance for late reads stays: a read can still show any of the engine's writes from the latest one the stream has shown onward. The one case this gives up is the engine writing the same value twice, with different writes in between, all within the moment pipewire-pulse takes to apply a write. Fade steps are time-based and don't repeat. Writing the same value twice in a row (re-asserts) is unaffected.

2. A queued outside report could override a newer in-app volume. SetVolume now advances the write sequence at once (StreamVolumeWatch::mark), even when its fade will write the stream only on a later tick. A report read before the command is then older than the engine's latest write and is ignored. A report read after it follows the command. So the set_volume_commands an accepted OutsideVolume carries is the count it was read under, and the bridge's check stays correct.

New regressions:

  • a_volume_set_elsewhere_to_one_the_app_set_while_paused_is_taken (engine) and a_value_written_again_after_writes_the_stream_did_not_show_is_the_latest (unit). Both fail with position instead of rposition.
  • a_volume_set_elsewhere_before_one_set_in_the_app_does_not_replace_it (engine) covers the exact interleaving: the 66 % report is queued, then SetVolume(0.3) arrives and the fade's first tick only starts its clock. After the reports are handled, the volume stays 0.3, no OutsideVolume is sent, and the fade reaches 30 %. It fails without the mark.
  • a_read_before_a_volume_change_the_engine_has_yet_to_write_tells_nothing (unit).

Private PipeWire check rerun: same results as the table above. No false outside changes from fades, the restart or the track change; outside volume, mute, unmute and the paused change all reach the engine.

Validation: TMPDIR=$HOME/.cache/ferrous-ui373/tmp-575 ./scripts/run-tests.sh passes:

  • Rust: fmt, check, strict Clippy and cargo-audit pass; 1819 tests pass, 5 ignored.
  • UI: CTest 104/104.

Review round 2

A superseded outside change, with the same volume set again, left the stream elsewhere. This happened when a SetVolume equal to the volume already applied (a slider released where it was, or MPRIS) set aside a queued outside report. Nothing then wrote the stream, so it could stay at the outside level while Ferrous showed its own.

Such a SetVolume now writes the volume to an output whose sink controls the stream volume, when no fade or silenced phase is going to write it. Other outputs, and commands that change the level, behave as before. The extra write happens only for a repeated level, which is cheap.

Regression: the_same_volume_set_in_the_app_after_one_set_elsewhere_plays. The engine plays steadily at 80 %, a 66 % set elsewhere is queued, and then SetVolume(0.8) is handled. The stream ends at 80 % and the engine reports 80 %. It fails without the fix.

Trade-off of matching the latest write (round 1), kept as is: suppose the engine writes the same level twice, with different writes in between, within the moment pipewire-pulse takes to apply a write. A late read of the first occurrence then drops the writes in between. If the stream later shows one of them, it is taken for an outside change. Two cases can reach this in theory:

  • a fade that reverses and revisits a level within SAME_LEVEL (1e-4 on the cubic scale);
  • a quick slider wiggle back to the exact same value.

Neither has turned up in testing. A wrongly taken change only makes Ferrous show the level the stream really plays at.

Validation: TMPDIR=$HOME/.cache/ferrous-ui373/tmp-575 ./scripts/run-tests.sh passes:

  • Rust: fmt, check, strict Clippy and cargo-audit pass; 1820 tests pass, 5 ignored.
  • UI: CTest 104/104.

@Tiaxi
Tiaxi merged commit be24d93 into main Nov 10, 2026
5 checks passed
@Tiaxi
Tiaxi deleted the fix/follow-outside-stream-volume branch November 10, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: follow volume changes made outside the app, such as Plasma's per-app slider

1 participant