Repository navigation
fix: follow volume changes made outside the app, such as Plasma's per-app slider - #577
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'snotify::volume/notify::mute. A change made in Plasma never reached Ferrous's slider, its saved setting or MPRISVolume. 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)StreamVolumeWatchconnectsnotify::volumeandnotify::muteon everyGstStreamVolumeelement inside the output bin: those it has now, and those it adds later throughdeep-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'soutput_applies_volume()find. Outputs without one (alsasink, fakesink,pipewiresinkor otherFERROUS_GST_OUTPUT_SINKoverrides that don't control stream volume) get no handler and behave as before.volumeandmutethere (pulsesink's read asks the server) and sends a report over a channel. The engine loopselect!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.set_propertyon its own thread are skipped at the source (a thread-local set during the write).PlaybackEvent::OutsideVolume { level, set_volume_commands }.begin_track_startand the startup fade all bring each new run's sink to it.volumeis linear. It is converted withgst_stream_volume_convert_volume(LINEAR → CUBIC), the same cubic scale as Ferrous'svolume_cubicand Plasma's percentage. 66 % round-trips exactly (unit test), andpactl … 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
rememberedVolumeBeforeMuterestores the level. So an outside mute is shown the same way:Volumeread 0. The mute button shows muted, and QML remembers the level from before.muteat Ready/Null while it is muted, so playsink starts the new stream muted, at the level.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)SetVolumenow goes throughcommands::command_volume, which counts them (BridgeState::volume_commands_sent).OutsideVolumeevent is taken only if the engine had handled all of the bridge'sSetVolumecommands 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.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.BridgeClient::volume(),TransportBar's slider andMprisController::volume().Verified on a private PipeWire server
The setup:
pipewire,pipewire-pulseand WirePlumber underdbus-run-session, with temporary XDG and runtime directories, every hardware monitor disabled,support.dbus = falseand one null sink. A guard checked thatpactl infonamed the private socket before anything ran. A scratch test (not committed) drove the realPlaybackEnginewith the default output (autoaudiosink → pulsesink) and usedpactlon Ferrous's sink input. It never touched the user's server or devices.set-sink-input-volume 66%while playingSetVolume(0.3): a fade on the stream50%from outsideset-sink-input-mute 1set-sink-input-mute 0SetVolume(0.7)40%while paused, then PlayKnown 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. NoOutsideVolumecomes 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_volumeunit 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.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 latestSetVolumeis ignored. It also works through the drain path.volumeTheBackendSendsReachesTheSliderAndMpris: a settings volume from the backend reachesBridgeClient::volume()(the slider's binding) andMprisController::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: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.
classifymatched 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.
SetVolumenow 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 theset_volume_commandsan acceptedOutsideVolumecarries 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) anda_value_written_again_after_writes_the_stream_did_not_show_is_the_latest(unit). Both fail withpositioninstead ofrposition.a_volume_set_elsewhere_before_one_set_in_the_app_does_not_replace_it(engine) covers the exact interleaving: the 66 % report is queued, thenSetVolume(0.3)arrives and the fade's first tick only starts its clock. After the reports are handled, the volume stays 0.3, noOutsideVolumeis 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.shpasses:Review round 2
A superseded outside change, with the same volume set again, left the stream elsewhere. This happened when a
SetVolumeequal 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
SetVolumenow 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 thenSetVolume(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:
SAME_LEVEL(1e-4 on the cubic scale);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.shpasses: