Skip to content

Read sink volume/mute at startup instead of assuming 100% - #12

Merged
fbnlrz merged 6 commits into
mainfrom
dev/1.0.10
Jul 27, 2026
Merged

Read sink volume/mute at startup instead of assuming 100%#12
fbnlrz merged 6 commits into
mainfrom
dev/1.0.10

Conversation

@fbnlrz

@fbnlrz fbnlrz commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What does this change?

Fixes a bug where channels restored to 0% by the session manager (WirePlumber) would display as 100% in the UI while being silent. The root cause was that init_virtual_devices would reset all sinks to 100%/unmuted after creation, racing against WirePlumber's restore of the remembered level per node name. Since WirePlumber wins that race, the write was ineffective and only desynchronized the UI from the actual audio state.

This change:

  1. Removes the volume/mute reset writes from init_virtual_devices
  2. Adds a new sink_state() method to AudioBackend to read the actual volume/mute from sinks
  3. Adds adopt_live_channel_state() to MixerState to populate strips with observed values
  4. Implements sink_state() for both PipeWire (native) and pactl (fallback) backends
  5. Calls the read path after sinks are created and wired, giving WirePlumber time to apply its restore

The strips now always reflect what the backend is actually doing, with 100%/unmuted as a documented fallback only for channels the backend cannot report on.

Closes the regression where silent channels showed 100% volume.

Type

  • Bug fix
  • New feature
  • Device support
  • Docs / website
  • Refactor / chore

Checklist

  • npx tsc --noEmit passes
  • npm test passes
  • cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings is clean
  • cargo test --manifest-path src-tauri/Cargo.toml passes
  • For device changes: verified on real hardware (say which device)

Notes for the reviewer

The key insight is that the session manager's restore happens asynchronously after sink creation and wins any race against writes from the app. By reading the state back instead of writing it, the UI always shows what the user will actually hear. The read is deliberately placed after routing/EQ/mix setup to give WirePlumber time to apply its restore.

Added comprehensive unit tests covering:

  • strips_take_volume_and_mute_from_the_sinks: Verifies strips adopt observed values
  • unreadable_channels_keep_the_default_instead_of_a_guess: Ensures no invented values
  • parses_sink_volume_and_mute_for_the_live_state_read: Pins pactl parsing against real output
  • a_sink_without_a_volume_map_reports_nothing: Confirms sinks without volume data report unknown
  • observed_state_of_an_unknown_node_is_unknown: Verifies PipeWire doesn't fabricate state

Version bumped to 1.0.10.

fbnlrz added 6 commits July 27, 2026 19:40
A channel could be silent while its fader read 100%. Routing was correct and
the graph was wired; the sink was simply at zero and nothing said so. Moving
the fader fixed it, because that is the first time anyone writes the value.

init_virtual_devices set every new sink to 100% and unmuted, commented as a
"known starting point". That starting point does not exist: the session
restores sink volumes per node.name and applies its value after ours.
Measured rather than assumed - set sink_music to 42% with pactl, restart
Inari, and it comes back at 42%, not 100. Meanwhile init_defaults filled the
strips with 100% and the UI reported that, so the interface and the audio
disagreed with no way for a user to tell which was lying.

So the volume is now read instead of written. The native backend already
mirrors volume and mute from PipeWire param events, so this costs no new
round trip; the pactl fallback parses what it already lists. A backend that
has not yet seen a Props event answers "unknown" rather than the placeholder
it was constructed with - reporting that would have recreated the same
illusion - and only then does the strip fall back to 100%, as a documented
fallback rather than a claim.

Verified on a live session: with the channels at 100 / 89 / 87 / 100 the
strips now read 100 / 89 / 87 / 100. Before, all four would have read 100.
Volumes also survive a restart now, which they appeared to do all along.
A soundboard whose point is that other people hear it. Clips go straight
into sink_mic, next to the already-processed voice, and PipeWire mixes them
there - deliberately not through the mic chain, where the noise gate would
chop a clip up the moment it got quiet and the compressor would squash what
survived. A second copy goes to your own output, because playing blind is
how you talk over your own clip.

Playback is native rather than a subprocess, and that was a measurement, not
a preference: pw-play --target sink_mic on a live session does not reach
sink_mic at all - the session routes it to the headset instead. The simple
route fails the whole promise silently, with the sound in your speakers and
nothing in the chat.

One clip at a time, and a press toggles. Pressing the lit pad stops it;
pressing another takes over. That decision is made under one lock in the
backend rather than as stop-then-play from the UI, because over the remote
those two requests can overtake each other and the stop then kills the clip
it was supposed to replace. The command answers whether the clip is playing
now, so a pad can redraw without asking again.

Clips are addressed by id and never by path - the tablet and, later, a
hotkey can fire them, and no filename may travel that way. ClipInfo carries
no path at all. Curating the board (adding, renaming, removing, levels)
stays on the desktop, where the file dialog is; the remote may only fire,
stop and read. WAV and FLAC decode in-process, so a board works without
ffmpeg; compressed formats use the system ffmpeg the same way the OLED media
path does, and the UI says which is missing instead of failing quietly.

Mic ducking is opt-in and off by default. It applies after the limiter, so
the compressor's makeup cannot hand the attenuation back, ramps over 15 ms
so there is no click, and rides in its own value: the gain you configured is
untouched before, during and after. Across a takeover the attenuation is
explicitly left in place rather than released and re-applied, which would
have put an audible jump in your voice at the exact moment the clips swap.

Two bugs the tests caught rather than a user: an unlinked clip stream is
driven by the dummy driver and free-runs, draining a one-second clip in
fifty milliseconds before its links existed - clips now start inactive and
are released once every destination is connected. And a status poll landing
inside the ducking write debounce flipped the switch back under the user's
finger.
The site still described v1.0.7. Four features shipped since then had no
page at all, and several existing pages had drifted from the code.

New pages:

  guide/remote.md      Serving the UI to a tablet: binding, QR pairing,
                       what the allowlist permits and what it refuses,
                       and a plain statement that the token crosses the
                       LAN in the clear until the TLS work lands.
  features/media.md    The MPRIS transport, player selection, and why the
                       position interpolates between polls.
  features/hotkeys.md  The four actions, why none are bound by default,
                       and what to do when Wayland refuses the grab.
  reference/cli.md     The commands against a running instance, including
                       that they never raise the window - which is the
                       point when one is bound to a key mid-game.

Corrections to existing pages:

  - The Aerox table labelled 1874/1876 as an undifferentiated "WOW" pair.
    They are the dongle and cable variants respectively; split into the
    rows the code actually distinguishes.
  - The FAQ said "there are no global hotkeys". There are, since 1.0.8.
  - Channel volumes now survive a restart (1.0.10), so the mixer page and
    troubleshooting describe reading sink state instead of the old
    "silent at 100 %" symptom.
  - Clip count 28 -> 29, and the welcome splash is documented.

Screenshots: mixer, apps, media, mic, headset, oled and eq retaken at 2x
against the current build; mouse replaced (it still showed v1.0.0 and a
rail without the Media entry); hotkeys, the remote settings section and
two tablet views are new.

The pairing token pictured in the remote screenshot was regenerated
afterwards, so the QR code in it opens nothing.
The equalizer shot had every band at zero and the toggle off, which
illustrates the panel but not the feature. Replaced it with the Chat
channel's actual configuration - Dialogue Clarity, a high-pass at 80 Hz
and a lift through the speech range - so the graph shows what the
controls underneath it do. Alt text now describes the curve.

Nothing was changed to stage the picture: eq.json is byte-identical
before and after.
@fbnlrz
fbnlrz merged commit 6602233 into main Jul 27, 2026
6 checks passed
@fbnlrz
fbnlrz deleted the dev/1.0.10 branch July 27, 2026 21:47
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.

1 participant