Files
pipewire-soundpad/pwsp-gui
785b835237 feat: separate monitoring and output streams (#178)
* feat: split playback into separate monitoring and mic streams

AudioPlayer held a single rodio stream. WirePlumber auto-connected its node to
the default sink (that is the monitoring path) while PWSP explicitly linked the
very same node into pwsp-virtual-mic. Both paths were fed by one Player, so
Player::set_volume scaled them together and they could not be separated — hence
the report of "volume 2 is fine on the other side but deafening locally".

There are now two independent streams with independent gains:

  mic stream        --ensure_route()--> pwsp-virtual-mic
  monitoring stream --ensure_route()--> selected Audio/Sink (or WirePlumber's
                                        choice when no device is pinned)

PipeWire side:
- DeviceType::Sink, so sinks are discoverable like inputs already were, plus the
  playback_* port mapping that Audio/Sink nodes need (monitor_* stays unmapped:
  linking a stream there would be a feedback loop).
- Link globals are tracked in the registry listener; GetLinks/DestroyGlobal make
  ensure_route() possible. It points a node at one target and prunes every other
  link leaving it, creating before pruning so the node is never left unlinked —
  otherwise WirePlumber's autoconnect would re-attach it behind our back.
  Replaces link_player_to_virtual_mic().

Audio side:
- PlayerPair wraps the two rodio Players of a track so transport controls reach
  both from one place instead of being fanned out at every call site.
- Streams are identified by diffing our own stream nodes before and after opening
  one, not by indexing a sorted list: PipeWire reuses freed node ids, so the
  stream opened second can end up with the lower id. Only nodes that look like
  ours are ever considered — pruning a stranger's node would silence another
  application. Opens are therefore sequential, and node discovery checks the
  graph before its first sleep to stay off the play latency path.
- Streams are dropped once nothing is playing, as before: an open stream keeps
  the audio device busy and stops laptops from suspending. The routing is
  rebuilt on the next play.
- Each track decodes twice, once per path. rodio's Buffered is the only shareable
  source and it cannot seek, which the position slider depends on.
- effective_gain() is the single home of the master * track * multiplier math and
  collapses non-finite or negative gains to silence.

Protocol, config and GUI:
- get/set_monitoring_volume, get/set_mic_volume, get/set_output, get_outputs.
- DaemonConfig gains default_output_name, default_monitoring_volume and
  default_mic_volume.
- Volumes arriving over IPC are validated: hotkeys store raw Request JSON, so a
  bad value can reach the daemon without passing through the CLI.
- The footer carries a monitoring slider and a mic slider, both running to 200%,
  plus an output combo box; with four widgets it no longer fits on one line and
  is laid out as two rows.
- SliderLatch owns the "local value wins while the user is interacting" logic
  every slider needs — the daemon is polled at 60 Hz, so without it a slider
  fights the user mid-drag and snaps back on release. That was previously
  copy-pasted per slider as a value/dragged/ignore-until triple.

Fixes along the way: setting the volume multiplier no longer overwrites the
master volume, and get_volume(Some(id)) reports the track's own volume instead of
the product of all three factors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): add monitoring-volume, mic-volume and output commands

get/set volume keeps its old meaning — without --id it moves both masters at
once, which is the "make everything quieter" shortcut. The new commands address
one path each, and values above 1.0 pass through on purpose: amplifying what
goes into the microphone without deafening yourself is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): move device selection into settings

The footer tried to hold two combo boxes, two sliders and two buttons on one
line. Their labels alone run ~300px, so at the 800px minimum window size the
sliders and buttons were pushed off the edge and simply vanished.

Device pickers are set-once controls and belong next to the theme selector, so
they move to the settings screen. The footer keeps the two volume sliders, which
are the ones worth reaching for mid-call, and now fits at the minimum width.

The right-edge spacer is also clamped at zero: computed negative, it used to
shove the buttons out of view rather than merely crowding them.

Verified with screenshots at 800px and 1200px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): align the footer volume icons and sliders

The speaker icon and its slider sat a couple of pixels above the microphone
pair. A horizontal layout centres each widget against the row height known when
that widget is placed, so a row that grows while being filled leaves whatever
was added first sitting too high.

Every footer element is now allocated the same box height, which makes the
centring independent of placement order, and the row is given that height up
front. Measured from screenshots at 800px: icon ink centres were 776.5 and
780.5, now both 776.5; slider rails were 2.5px apart, now 0.5px, which is
sub-pixel rounding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): let the output device be unpinned again

The output picker listed devices but no way back, so choosing one was a one-way
door. It now offers "system default" as the first entry, which sends set_output
with an empty name; the daemon takes that as "stop pinning".

Unpinning deliberately leaves the existing link alone rather than tearing it
down. WirePlumber's autoconnect only runs when a node first appears, so removing
the link would strand the monitoring stream with no output at all. Streams close
as soon as playback stops, so the next sound opens a fresh node that gets routed
like any other application's.

Picking the default target ourselves was tried and reverted: the global
default.audio.sink is only a fallback, and a per-stream target.node overrides it.
On this machine that meant monitoring would have gone straight to the hardware
sink, bypassing the user's EasyEffects chain that WirePlumber routes it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: trim comments and drop an abstraction that earned nothing

Removes the RouteTarget trait and its two unit-struct implementations. They
existed to let route() resolve a target lazily, but both call sites resolve one
line earlier just as well, so a trait, two types and an impl block collapse into
one parameter.

Narrows should_sync, commit and prune_links_from to private: all three were
public but only ever called from within their own module.

Comment pass: drops the decorative section dividers that this file never had,
and rewrites the ones that leaned on a particular machine's setup or narrated a
past bug. What is left explains things the code cannot: why streams close when
idle, why they are opened one at a time, why a link is created before stale ones
are pruned, why playback_* maps to inputs while monitor_* stays unmapped, and
why the footer row height is pinned up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:36:10 +03:00
..