Repository navigation
fix: silence a resume that was muted while paused - #572
Merged
Merged
Conversation
Tiaxi
force-pushed
the
fix/sink-level-gain
branch
from
November 10, 2026 08:42
7816174 to
2606960
Compare
Tiaxi
force-pushed
the
fix/sink-level-gain
branch
from
November 10, 2026 08:59
2606960 to
af6dc1d
Compare
This was referenced Nov 10, 2026
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 #557.
What was verified first
The gain already reaches the held audio with the real sink. I checked this with a real GStreamer test on a private PipeWire server:
pipewire,pipewire-pulse, and WirePlumber with every hardware monitor disabled, one null sink, a temporary runtime dir, andpw-recordcapturing the null sink's monitor. The test played a tone through playbin3 into the same output bin Ferrous builds (capsfilter ! audioconvert ! audioresample ! capsfilter ! <sink>), paused, set the volume to 0, then played.autoaudiosink→pulsesink(what Ferrous builds on Plasma)pulsesink, aGstStreamVolumealsasink(no stream volume)playsink looks for a
GstStreamVolumeinside the audio-sink bin (gst_bin_get_by_interface). It findspulsesinkinsideautoaudiosink, so production already applies the volume at render time, in the sound server.Why production still had the burst: the engine deferred a volume change made while paused (
volume_ramp_pendingonly steps while Playing). Then, after Play, it faded toward the new level. That is where the wrong state came from. The fakesink tests take the software-volume path, which is why the burst looked like a sink problem.Real engine, same private server (scratch program driving
PlaybackEngine, not committed):Changes (
src/playback/backend_gst.rs)output_applies_volume()runs the same check as playsink: the output bin holds aGstStreamVolumeelement. It answersNonewhile the output bin is still below Ready. A new run's output stays at Null, with autoaudiosink's placeholder, until playsink sets it up during preroll; I measured this on the private server. A test pins the answer to playsink's actual choice: whether avolumeelement is in the pipeline.OutputRise::SilencedUntilPreroll, thenhandle_async_done→play(). The decision is made on the configured output, the same way a restarted run already waits for its seek. The run couldn't play before its preroll anyway, so nothing is heard in between. Pause in the meantime keeps it paused. Another Play in the meantime, such as a second MPRIS request, keeps waiting (round-2 finding): the guard tests both silenced states. A preroll that never completes is reported like a run that never plays, becauseplaying_state_requested_atis set.mute()before a restart, went to the old sink, and the new one started at 1.0 (checked with autoaudiosink picking fakeaudiosink). So the restarted run's preroll was at full gain until the engine next set the volume, which could come after the run began rendering.set_pipeline_statenow sets the engine's volume again whenever the pipeline drops to Ready or Null.OutputRisestate: one enum (Steady/Silenced/SilencedUntilPreroll/StartupFade) replaces thestartup_gain_rampbool.mute()setsSilencedwhen a run is torn down, switched or restarted. Until that run plays, no fade steps the volume (volume_ramp_pending,poll_volume_ramp). This fixes item 3 for both kinds of output: a restart's mid-track preroll is no longer taken at a partial gain while a fade climbs, then cut to silence.play()and the restarted run (start_playing_restarted) callbegin_track_start()when the pipeline plays from 0. "From 0" means the newest queued or in-flight seek goes there, or the pipeline rests there. This covers:Fallback: outputs that don't apply the volume themselves
This means
alsasink,pipewiresinkthroughFERROUS_GST_OUTPUT_SINK, and fakesink. Behaviour is main's:Nothing upstream can reach the audio such a sink already holds: its preroll buffer and its ring buffer. So a mute while paused still plays that held audio at the old volume, then fades out. Fixing that would need a re-preroll, which this PR doesn't add because it can cancel a handed-over successor. Only the restart hold (item 3) changes for these outputs.
Tests
The tests run against the GStreamer backend with generated WAV fixtures and no audio device. The new output is
late_sink::LateSink, a test bin that creates afakeaudiosink(plugins-bad-free) only at Null → Ready and drops it at Ready → Null, exactly as autoaudiosink does. So the tests see production's timing: noGstStreamVolumebefore playsink sets the output up, and a new sink element for every run.fakeaudiosinkimplementsGstStreamVolume, so playsink gives it the volume and adds no volume element, exactly as withpulsesink.record_rendered_at_volumeuses its render-timehandoff(after the preroll wait) and scales each buffer by the sink'svolume/muteat that moment. That models a sound server applying stream volume as it plays. What it does not exercise: the PulseAudio/PipeWire server itself. That part is covered by the private-server checks above, which are not in CI.Rendered output buffers, sink-side gain:
muting_while_paused_silences_every_sample_of_the_resumeunmuting_while_paused_resumes_at_the_volume_without_a_gappaused_starts_at_a_tracks_start_play_their_attack_at_the_volume: Cue(0); Cue(0) then a volume change; a track picked while paused; a track picked while paused, then Play before it prerolls (its reads are held so the output can't have picked its sink yet), with Play sent twice; a paused seek to 0; Play before that seek lands.muting_while_a_fade_in_is_paused_silences_the_resume: every rendered buffer after the resume is silent.unmuting_while_a_fade_in_is_paused_fades_in_on_resume: the resume fades in from the mute's silence and rises to the volume, never above it.Rendered output, both outputs:
a_cued_start_within_a_track_fades_ina_restart_for_a_seek_fades_in_from_a_silent_prerollgapless_switch_plays_at_full_levelrepeat_one_replay_plays_at_full_levelEngine state:
restart_for_a_seek_during_a_fade_fades_in_from_silence, on both outputs, now asserts that the output stays silent and that no fade steps while the restart prerolls.a_volume_change_while_paused_applies_at_once_to_an_output_that_applies_ita_volume_change_while_paused_waits_for_playback_when_playsink_applies_it(fallback)paused_starts_at_a_tracks_start_play_at_once_on_an_output_that_applies_the_volumea_cued_start_within_a_track_fades_in_on_an_output_that_applies_the_volumepaused_starts_fade_in_from_silence_when_playsink_applies_the_volume(fallback, renamed frompaused_starts_still_fade_in_from_silence)playsink_gives_the_volume_to_an_output_that_applies_itplay_before_a_track_picked_while_paused_prerolls_waits_for_its_output, on both outputs: Play is taken while the output is unconfigured, and the pipeline isn't asked to play. A second Play keeps waiting. Pause in between keeps it paused. After the preroll, the run starts at once with sink-side gain, and fades in otherwise.Round 1 regression check: with each round-1 fix undone in turn (the deferral, the lower-target-at-once rule, and the volume re-set at Ready/Null), these fail: the deferral state test, the picked-track rendered case, both paused fade-in tests, and both restart tests.
Round 2 regression check: with the guard back on plain
Silenced, the deferral state test and the rendered paused-start test both fail.Run against main's engine code (round 0) (the new tests plus the one new helper they call): 6 fail. They are the mute and unmute resumes, the paused starts (state and rendered), the volume change at once, and the restart hold. The rendered restart test passes on main, because main's partial gain depends on preroll timing. The state test is the deterministic regression for that.
Dependencies
gstreamer1-plugins-bad-freeforfakeaudiosink. It is added to the Rust CI job and to INSTALL.md.Requiresit, and its%checkruns only CTest, so the spec is unchanged.Validation
TMPDIR=$HOME/.cache/ferrous-ui373/tmp-557 ./scripts/run-tests.sh(Rust and UI) passes:The branch is rebased on current main.
Round 1 on the private server, real engine at volume 0.5 (2000 of 16000):
The same run showed something that predates this PR: on pulsesink, a mid-track startup fade is mostly invisible. Audio reaches the server's mix about 40 ms after the engine's fade starts, and at volume 0.5 the 90 ms-scale fade completes in 45 ms. I left it alone here.
The private PipeWire checks above were run by hand on Fedora 44 (GStreamer 1.28.9, PipeWire 1.6.11, WirePlumber 0.5.19). They used temporary XDG and runtime directories under
dbus-run-session, no audio device, and never reached the user's session. CI can't run them.