Skip to content

fix: silence a resume that was muted while paused - #572

Merged
Tiaxi merged 4 commits into
mainfrom
fix/sink-level-gain
Nov 10, 2026
Merged

Tiaxi merged 4 commits into
mainfrom
fix/sink-level-gain

Conversation

@Tiaxi

@Tiaxi Tiaxi commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

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, and pw-record capturing 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.

Output Volume element added by playsink Mute while paused, then Play
autoaudiosink → pulsesink (what Ferrous builds on Plasma) none: playsink gives the volume to pulsesink, a GstStreamVolume silent
alsasink (no stream volume) yes, upstream of the sink a 405 ms burst at the old level: the preroll buffer and the sink's ring buffer

playsink looks for a GstStreamVolume inside the audio-sink bin (gst_bin_get_by_interface). It finds pulsesink inside autoaudiosink, 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_pending only 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):

Step main this branch
Mute while paused → Play 90 ms burst fading down from full level silent
Unmute while paused → Play fades in from ~10 % full level from the first 5 ms window
Cue(0) → Play fades in full level at once
Cue(5 s) → Play fades in fades in (kept)

Changes (src/playback/backend_gst.rs)

  • output_applies_volume() runs the same check as playsink: the output bin holds a GstStreamVolume element. It answers None while 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 a volume element is in the pipeline.
  • Play before a new paused run has prerolled (for example, a track picked while paused, then Play straight away; round-1 finding 1): when the output hasn't picked its sink yet, Play doesn't commit to a fade. The run plays once it has prerolled: OutputRise::SilencedUntilPreroll, then handle_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, because playing_state_requested_at is set.
  • The volume carries to each new run's sink: autoaudiosink makes a new sink element for every run. playsink pushes its stored volume to that sink only when the volume was set after the old chain went away. A volume set before the run's Null, such as 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_state now sets the engine's volume again whenever the pipeline drops to Ready or Null.
  • Volume change while paused, with sink-side gain: it applies at once. Muted while paused, the resume plays nothing at the old volume. Unmuted, it resumes at the volume with no gap. It still waits while the output is silenced for a run.
    • A fade-in interrupted by the pause (round-1 finding 2): a higher target waits, and the fade rises on from where it got to when playback resumes. A lower target, such as a mute, applies at once.
  • New OutputRise state: one enum (Steady / Silenced / SilencedUntilPreroll / StartupFade) replaces the startup_gain_ramp bool. mute() sets Silenced when 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.
  • Fade-free paused starts at a track's beginning, with sink-side gain: play() and the restarted run (start_playing_restarted) call begin_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:
    • Cue(0) → Play
    • a track switch while paused → Play
    • a paused seek to 0 → Play
    • a volume change while paused at 0
    • Play before a paused seek lands, including a seek that restarts the run
  • Kept from fix: don't fade in a track that starts from its beginning #551: a mid-track start from a preroll silenced for a cue or restart still fades in from silence. AC-3/DTS starts keep their silence hold.
  • Gapless and Repeat-One: untouched. No path adds a seek.

Fallback: outputs that don't apply the volume themselves

This means alsasink, pipewiresink through FERROUS_GST_OUTPUT_SINK, and fakesink. Behaviour is main's:

  • A volume change while paused waits, and the output fades to it after Play.
  • Paused starts fade in from silence.

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 a fakeaudiosink (plugins-bad-free) only at Null → Ready and drops it at Ready → Null, exactly as autoaudiosink does. So the tests see production's timing: no GstStreamVolume before playsink sets the output up, and a new sink element for every run. fakeaudiosink implements GstStreamVolume, so playsink gives it the volume and adds no volume element, exactly as with pulsesink. record_rendered_at_volume uses its render-time handoff (after the preroll wait) and scales each buffer by the sink's volume/mute at 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_resume
  • unmuting_while_paused_resumes_at_the_volume_without_a_gap
  • paused_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_in
  • a_restart_for_a_seek_fades_in_from_a_silent_preroll
  • gapless_switch_plays_at_full_level
  • repeat_one_replay_plays_at_full_level

Engine 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_it
  • a_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_volume
  • a_cued_start_within_a_track_fades_in_on_an_output_that_applies_the_volume
  • paused_starts_fade_in_from_silence_when_playsink_applies_the_volume (fallback, renamed from paused_starts_still_fade_in_from_silence)
  • playsink_gives_the_volume_to_an_output_that_applies_it
  • play_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

  • The tests need gstreamer1-plugins-bad-free for fakeaudiosink. It is added to the Rust CI job and to INSTALL.md.
  • The RPM already Requires it, and its %check runs only CTest, so the spec is unchanged.

Validation

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

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

The branch is rebased on current main.

Round 1 on the private server, real engine at volume 0.5 (2000 of 16000):

  • A track picked while paused, then Play at once: 2000 from the first 5 ms window.
  • A mid-track cue that is played, paused 45 ms into its fade-in, then muted and played again: silent.

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.

@Tiaxi
Tiaxi force-pushed the fix/sink-level-gain branch from 7816174 to 2606960 Compare November 10, 2026 08:42
@Tiaxi
Tiaxi force-pushed the fix/sink-level-gain branch from 2606960 to af6dc1d Compare November 10, 2026 08:59
@Tiaxi
Tiaxi merged commit 0a8e4b0 into main Nov 10, 2026
5 checks passed
@Tiaxi
Tiaxi deleted the fix/sink-level-gain branch November 10, 2026 09:24
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: muting while paused should silence the very first audio on resume

1 participant