Read sink volume/mute at startup instead of assuming 100% - #12
Merged
Conversation
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 tablet" This reverts commit c9c0379.
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.
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.
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_deviceswould 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:
init_virtual_devicessink_state()method toAudioBackendto read the actual volume/mute from sinksadopt_live_channel_state()toMixerStateto populate strips with observed valuessink_state()for both PipeWire (native) and pactl (fallback) backendsThe 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
Checklist
npx tsc --noEmitpassesnpm testpassescargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warningsis cleancargo test --manifest-path src-tauri/Cargo.tomlpassesNotes 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 valuesunreadable_channels_keep_the_default_instead_of_a_guess: Ensures no invented valuesparses_sink_volume_and_mute_for_the_live_state_read: Pins pactl parsing against real outputa_sink_without_a_volume_map_reports_nothing: Confirms sinks without volume data report unknownobserved_state_of_an_unknown_node_is_unknown: Verifies PipeWire doesn't fabricate stateVersion bumped to 1.0.10.