Skip to content

Wave XLR MK.2, 48 V phantom power, and a mixing matrix with per-mix outputs - #8

Open
NyleGarcia wants to merge 100 commits into
rikkichy:mainfrom
NyleGarcia:wave-xlr-mk2-and-output-routing
Open

Wave XLR MK.2, 48 V phantom power, and a mixing matrix with per-mix outputs#8
NyleGarcia wants to merge 100 commits into
rikkichy:mainfrom
NyleGarcia:wave-xlr-mk2-and-output-routing

Conversation

@NyleGarcia

@NyleGarcia NyleGarcia commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Wave XLR MK.2 support, 48 V phantom power, and a mixing matrix with per-mix outputs.

OpenWave

Sources are rows, mixes are columns, one cell per pair. Above: an XLR Dock and an Arctis headset microphone grouped so only one is live at a time — the muted one is the red row — feeding a Personal Mix monitored on the headset, a Chat Mix published as a capture source for voice apps, and a Record Mix routed to no output at all but still recordable.

This started as "make my XLR Dock work on Bazzite" and grew into the routing layer needed to make it useful. It merges cleanly onto main and adds no dependencies — still stdlib, GTK4 and libusb.

It is a big branch, and I would rather it be reviewed in pieces than not at all. Happy to split it into separate PRs along the section headings below — say the word and I will.

Since this PR was opened

The branch has kept growing while it waited (the fork now ships it as v1.2.0). New since opening, each detailed in its own section below:

  • Multiple Waves at once — every connected Wave (two of the same model included) is opened, polled and ALSA-synced; a Device dropdown picks which one the sidebar controls drive.
  • Per-source FX with guided auto-calibration (Feature request: DSP FX controls of the new models Wave XLR mk2/pro #5) — low cut, gate, compressor, EQ and mono fold-down per source, with a two-measurement calibrator that sets the gate and compressor from the room's actual noise floor and speech level.
  • Scenes — named snapshots of the whole matrix (trims, mutes, sends, outputs, master volumes), applyable from the window or the session bus.
  • Per-source and per-mix metering, drawn only for the rows actually visible.
  • A push remote surface — stateful actions emit org.gtk.Actions.Changed, so a Stream Deck redraws the moment the mixer moves instead of polling.
  • Self-healing — watchdogs for the faults every byte-level check passes: xrun-accumulating captures (robotic audio) and running sinks whose kernel hw_ptr froze (silent output). Rate-limited, and they give up loudly rather than loop.
  • Device mute sync — a headset's own mute button and its matrix row stay in agreement, both directions.
  • Diagnostics export and docs/troubleshooting.md with the field notes behind the watchdogs.

The test suite grew with it: 375 cases, still stdlib-only.

Issues

Issue
#1 Feature request: +48 V toggle implemented
#6 Feature request: Support for Wave XLR Dock (Mk.2) implemented, verified on hardware
#7 Feature request: ID of devices implemented
#5 DSP FX controls implemented — per-source low cut, gate, compressor, EQ, mono, with guided auto-calibration

Hardware

MK.2 / XLR Dock (0fd9:00a6). It speaks the same vendor protocol as the Wave XLR, so the profile is a replace() of it rather than a copy; only the PID and the gain scale differ. The gain scale (256) was measured against the device, not guessed.

48 V phantom power lives at offset 6 of the Wave XLR config block, 0x01 on / 0x00 off. Found by diffing the block across a toggle and confirmed against the device's own +48 V indicator lighting up. Capability-gated via has_phantom, so the Wave:3 does not grow a control it has no wire for. Worth noting for #1: the Dock has no front-panel button for phantom at all, so on that hardware the application is the only way to switch it.

ALSA card matching now goes through /proc/asound/card*/usbid rather than matching the card's name. With a Wave XLR and an XLR Dock both connected, name matching picks whichever card sorts first — and the MK.2 enumerates as "Elgato XLR Dock", which does not contain "Wave" at all.

Control ranges are read from the driver instead of being hardcoded, and gain is shown in dB rather than as a raw hex word.

Routing

The core of the branch. docs/ARCHITECTURE.md explains the model; the two decisions worth reviewing:

Applications are moved, not copied. A source's audio is routed into exactly one mix cell by moving the stream, rather than being duplicated into every mix that wants it. Copying is the obvious implementation and it is wrong: two copies of one stream sum at the output and the application is audible at up to twice its level, with no control that fixes it. claim_streams gives every stream exactly one owner.

Trim multiplies into the per-mix loopback, not the intake sink. Setting a null sink's volume to zero leaves its monitor at full scale — measured, not assumed — because the pulse layer's flat-volume handling raises the stream to compensate. monitor.channel-volumes did not fix it. The per-mix loopback volume is the one control that demonstrably attenuates.

Also here: per-mix output devices (or none, for a mix that exists only to be captured); each mix published as a capture source, so Discord and OBS can select one directly; mixes that keep playing when the window is closed; and intake sinks pinned to priority.session=0 so one cannot win the default-sink election and silence the machine.

Sources and groups

Every Elgato capture input gets a row named after its device, which is #7 in practice: with two interfaces connected there is no single "the microphone" to speak of. The old hardcoded microphone row is retired.

Sources can be grouped by dragging one row onto another. Within a group exactly one microphone is live and one press hands it to the next — two microphones on one speaker comb-filter when both are open, so switching should be one gesture. Microphones in a different group are untouched, which is what a podcast with two people at one table needs and what a global default-source switch cannot express.

System, Game, Music, Browser and Voice ship pre-matched, with System as the catch-all.

Remote control

GApplication already exports org.gtk.Actions on com.github.openwave — it simply had nothing registered. The actions (switch-group, set-source-level, toggle-source-mute, toggle-fx, source-groups, snapshot, plus four for scenes) make the parts PipeWire cannot reach drivable from outside, with no IPC to invent and no daemon to run. The surface is push, not poll-only: stateful actions emit org.gtk.Actions.Changed (debounced, one signal per gesture), so a subscribed control surface redraws the moment the mixer moves — from the window, the hardware mute button, a scene, anything.

They route through the window rather than the config files on purpose: Mixer holds the same dict the window holds and rewrites sources.json whole on every save, so an outside write is discarded the moment a fader moves. Sends and trims are deliberately not exposed — they are re-applied on every reconcile, so a value set from outside would revert within a second.

I have written a Stream Deck plugin on top of this: https://github.com/NyleGarcia/openwave-streamdeck. Nothing here depends on it.

Devices, scenes and FX

device.scan() opens every connected Wave rather than the first match, keyed by profile:serial so two of the same model stay distinct; the sidebar grows a Device dropdown and the daemon pins one keepalive per source. Scenes snapshot the matrix that exists — source trims and mutes, cell sends, per-mix outputs and masters — and apply atomically, from the window or four session-bus actions.

Per-source FX are a filter chain inserted between the raw device node and the matrix — low cut, EQ and mono are PipeWire builtins, gate and compressor come from LADSPA swh-plugins (only loaded when enabled; a chain that dies under a config is remembered and not respawned into a crash loop). The auto-calibrator takes two guided measurements — silence for the floor, speech for the level — and sets the gate threshold and compressor from what it heard, measured on the raw node so re-calibration is never colored by the previous calibration.

Self-healing and device mutes

Two watchdogs in the daemon for the faults every ordinary check passes (both observed on real hardware, both documented with their diagnosis method in docs/troubleshooting.md): a capture accumulating xruns continuously sounds robotic while every byte-level check passes — the remedy is a card-profile cycle; a sink whose node runs while the kernel's hw_ptr froze plays silence — the remedy is a suspend/resume. Both act at most twice per incident, refill their budget only after a sustained quiet stretch (one clean window is a cycle settling, and refilling on it turns a persistent fault into an audible pop loop), skip muted captures, and log each thing once with the measured numbers.

A device's own ALSA-level mute (a headset's hardware button) and its matrix row also stay in agreement both ways: row mutes mirror to the device, external changes reach the row on edges so a poll can never undo a fresh click, and at first sight the row's deliberate state wins over whatever stale mute a session-manager restart left behind.

The shipped WirePlumber conf additionally pins priority.driver on Wave nodes: all ALSA nodes default to the same priority and a tie falls to the lowest object id, which was observed handing the graph clock to a wireless dongle whose jitter made the Wave's follower DLL resync ~23×/s — audibly robotic. The Wave's wired iso clock is the stable one; it should drive.

Tests, CI and docs

375 stdlib unittest cases covering matching, the stores, state migration, the generated PipeWire config, sink creation, device scaling, scenes, FX config rendering, the watchdog decisions and the mute-sync reconciliation. They import neither GTK nor libusb and need no display, audio server or hardware, so they run on a bare runner — .github/workflows/tests.yml does exactly that, plus a byte-compile.

The GUI, the USB protocol and the routing itself are still verified against hardware rather than unit-tested.

Verified on

Bazzite (Fedora 44, PipeWire), with a Wave XLR and an Elgato XLR Dock connected simultaneously, plus a SteelSeries Arctis Nova Pro Wireless as a second microphone and as an output.

Every routing claim above is from measurement — the flat-volume behaviour, the gain scale, the phantom offset and the default-sink election were each caught by something breaking audibly first.

The MK.2 enumerates as "Elgato XLR Dock" under product id 0x00a6 and is not
recognised, so the app reports no device on otherwise working hardware.

It speaks the original Wave XLR's vendor protocol unchanged. A probe dump
against hardware decodes gain @0 (0x4b00 of 0x5000), mute @4, HP volume @9
and low-Z @33 as the existing profile expects, and the devinfo serial lands
at offset 27 matching the serial in the ALSA card name. So the profile is a
clone with a new product id rather than a new layout.
find_wave_xlr_alsa() and the WirePlumber rule both matched only the literal
"Elgato_Wave_", but the MK.2 enumerates as "Elgato_XLR_Dock". On that
hardware mic and hp resolved to None and the WirePlumber properties were
never applied to any node.

The hp miss is not cosmetic: _do_start() guards the Personal->headphone
loopback behind `if self.hp`, so an unresolved hp silently skips it and the
Personal Mix has no outlet at all.

Regex alternation in the node.name match verified against WirePlumber 0.5.12.
…e's jack

_do_start() spawned the Personal->headphone loopback only `if self.hp`, where
hp is the Wave device's own headphone sink. When that sink does not exist the
guard fails silently: no loopback, no error, and the Personal Mix — which is
normally the system default sink — discards everything routed into it.

That is not an edge case. It happens whenever the card is set to an
input-only profile, which is the reasonable configuration for anyone
monitoring through a headset rather than the Wave's jack, and it is also what
happens on a device whose sink name is not recognised.

Resolution is now: explicit user choice, the Wave's own jack, the system
default, then the highest-priority output. Only sinks with a device.id are
eligible; virtual sinks are excluded because per-application sinks commonly
feed into the Personal Mix, so selecting one would close a feedback loop. A
stale or unplugged choice falls through rather than resolving to nothing.

Adds a Personal Mix Output picker to the device pane, persisted to mixes.json
under a reserved key ('output'; cell keys always contain a '.').
…acks

Two defects leaked child processes.

WaveXLRApp defined do_shutdown twice (app.py:770 and app.py:867). The second
binding silently replaced the first, so meter.stop_all() and mixer.stop() never
ran and every pw-loopback and pw-cat child outlived the app. Merged into one
definition that stops polling, tears down meters and loopbacks, then drops the
USB link.

Nothing reconciled Mixer._procs against process reality -- there was no poll()
or returncode check on those children anywhere. A loopback that died out of band
left a key that permanently blocked respawn, because _spawn_loopback returns
early on 'key in self._procs', and stayed a zombie because only
_destroy_loopback ever wait()s. Both were observable under a running instance:
pw-cat and pw-loopback defunct, parented to the app. _reap_dead() now runs at
the top of _reconcile_all.

Also glob wavexlr/*.py in the Makefile install rule instead of naming each file.
PKGBUILD already globs; the divergence meant a new module was silently omitted
from 'make install' and from the Nix derivation while working fine in a source
checkout.
Mix identity is currently a compile-time constant duplicated across
mixer.MIX_SINKS, setup.MIX_SINKS and two hardcoded tuples in app.py, so a mix
cannot be created, renamed or removed without editing four places. This adds
the store that later commits will drive those from. Nothing consumes it yet,
so this commit cannot change behaviour.

mixdefs.json is deliberately NOT the existing mixes.json: that path belongs to
Mixer._state and holds per-cell levels, so sharing it would let a slider move
clobber a definition.

Two fields exist that a single 'name' could not represent. 'sink' is stored
rather than derived from the id, so renaming a mix never renames the PipeWire
node an OBS or Discord capture points at. 'description' is what PipeWire
publishes as node.description, kept separate from the name shown in our UI for
the same reason.

load() distinguishes absent from unreadable rather than returning {} for both.
The consumer regenerates the PipeWire config from this store, so treating a
parse failure as 'the user has no mixes' would destroy every sink. A corrupt
file is quarantined to mixdefs.json.corrupt and replaced with the defaults.
Removes the four hardcoded mix lists: mixer.MIX_SINKS, setup.MIX_SINKS, the
add_mix block in app._build_ui and the ("personal","chat","record") tuples in
_wire_matrix_cells and _on_source_added. Mix membership is now per-instance
runtime state pushed in via Mixer.set_mixes, and the PipeWire config is
rendered from the store rather than byte-copied from the packaged file.

Still exactly three mixes and one output picker: this is a refactor whose
success criterion is that nothing observable moves. Verified the rendered
config is field-identical to the shipped one -- same node.name and
node.description for all three sinks, same factory, linger and channel-volume
properties -- so existing routings in other applications keep resolving.

Two ordering hazards fixed while here:

_reconcile_all iterated self._sources live and unlocked while set_sources
replaced that dict from the GTK thread; a mutation mid-iteration would raise
into _worker_loop's bare except and silently leave a mix unwired. Both axes are
now snapshotted under the lock.

set_sources enqueued a reconcile that could run before _do_start, routing cells
into sinks that had not yet been created or swept. Reconciles are now suppressed
until _do_start has run once, and set_sources/set_mixes share a coalescing key
so configuring both at startup costs one pass instead of two.

install_mixes refuses to write an empty config, since an unreadable store would
otherwise render to zero entries and delete every mix sink, and preserves a
hand-edited config as .bak before the first generated write.
Generalises the single Personal Mix output into one output per mix, so a mix
can be monitored on a chosen device or not monitored at all. Not monitoring is
the right default for a mix that exists only to be captured, which is what the
Chat and Record mixes are for, and it is now expressible rather than an
accident of there being no loopback.

State moves from a reserved scalar "output" to a nested dot-free "outputs"
mapping. The scalar is still written whenever the monitoring mix changes so a
downgrade keeps working for one release; the migration prefers an existing
per-mix entry, since that is newer than the scalar it would come from.

The migration runs from __init__ rather than from _load_state. _load_state is
what produces self._state, so writing from inside it would race its own caller
and could persist a half-built state. _load_state also gained the
isinstance(dict) guard its sibling sources.load() already had, since the
migration mutates whatever it returns.

resolve_output now accepts the sink list and default sink from the caller.
_respawn_all_output_loopbacks resolves every mix from one enumeration instead
of paying two subprocesses per mix.

The output loopback node is now openwave_loop_out_<mix_id> rather than
openwave_loop_personal_to_hp. Anything naming the old node -- a hand-written
WirePlumber rule, a pavucontrol assignment -- needs updating; the sweep still
matches on the openwave_loop_ prefix, so cleanup is unaffected.
The Wave XLR profile declared gain_scale=None, meaning 'opaque raw', so the UI
rendered gain as 0x4B00. The scale is not opaque: it is 256 raw units per dB,
the same as the Wave:3 already declares, which puts gain_max at 80 dB.

Measured on a Wave XLR MK.2 by driving the ALSA 'Mic Capture Volume' control
and reading the device's own gain word back over the vendor protocol at four
points across the range:

  ALSA 20.00 dB -> 0x1400   256.00 raw/dB
  ALSA 40.00 dB -> 0x2800   256.00 raw/dB
  ALSA 60.00 dB -> 0x3C00   256.00 raw/dB
  ALSA 75.00 dB -> 0x4B00   256.00 raw/dB

Exactly linear, so the displayed value now agrees with what every other mixer
on the system reports for the same control.

The placeholder shown before a device connects was the hex-formatted 0x0000,
which implied a real reading of zero; it is an em dash now.
_alsa_set_gain clamped numid=6 to 0-80 and _fw_gain_to_alsa clamped the same
way. On a Wave XLR that control's real range is 0-150 (0-75 dB in 0.5 dB
steps), so any firmware gain above 40 dB would have been silently halved when
mirrored to ALSA. The constant matches the Wave:3, whose gain_max of 0x2800 is
exactly 40 dB, so it was right for the device it was written against and wrong
for the other one.

Nothing is affected today: the mirror is gated on sync_alsa_gain, which is
False for the Wave XLR and True only for the Wave:3. It is a latent hazard
rather than a live bug, and it becomes live the moment that flag is enabled
for an XLR.

Ranges are now read once per control with 'amixer cget' and cached, so neither
setter assumes a range that belongs to a different device. The upper clamp
moved out of _fw_gain_to_alsa, which has no business knowing it, into the
setter, which does. Verified against card 3: numid=6 reports max=150 and
75 dB now maps to step 150 rather than being truncated to 80.
The mix column header becomes the control surface for a mix: a menu button
holding the output chooser, Rename and Delete, with the resolved output shown
inline so routing is visible without opening anything. The device pane's
'Personal Mix Output' group and its four methods are gone; their useful logic
(the 'Automatic - <device>' label, the '<name> (unavailable)' entry for a
remembered but absent device, Automatic pinned at index 0) moved into the
per-mix chooser.

The three built-in mixes are ordinary mixes -- renameable and deletable like
any other. That is safe because mixdefs.json stores 'sink' separately from
'name', so renaming never renames the PipeWire node another application
targets.

Header labels now ellipsize. set_size_request(220, 64) is a minimum, so a long
user-typed name previously stretched the whole column.

Fixes folded in from review, none of which the implementation had:

A mix description is typed by the user and reached both the generated config
and a pw-cli argument unescaped, so a name containing a quote or backslash
truncated the property and corrupted every sink defined after it. Escaping is
applied at render time, not at creation time, so it also repairs a description
already persisted by an earlier build.

install_mixes wrote a fixed temp path from ad-hoc threads: two overlapping mix
operations could publish a truncated config, and a stale snapshot could
resurrect a sink the worker had just destroyed. It now serialises on a lock and
writes through mkstemp in the destination directory.

A new mix reached the mixer only from the install success callback, so a failed
install left a drawn, persisted column whose every cell was silently inert for
the rest of the session. The mixer is now told unconditionally.

Default monitoring keyed off the literal id "personal", which the user can now
delete. It follows the first mix in insertion order instead.
A source could only ever be an application, matched against a stream. A
hardware capture device is a Source node, not a stream, so a headset
microphone could not be routed into a mix at all -- despite the built-in
Microphone row already doing exactly that job for the Wave device.

So this generalises the existing machinery rather than adding new machinery.
_reconcile_mic_cell became _reconcile_capture_cell, taking a capture node name;
the built-in row passes self.mic and a device source passes its stored node.
Source records gain a 'kind' discriminator, and a record without one is an app
source, so existing sources.json files keep working untouched.

list_capture_sources mirrors list_output_sinks: real hardware only, monitors
and OpenWave's own loopback nodes excluded.

Two fixes folded in from review:

The capture snapshot was read fail-closed by the UI and fail-open by the
routing gate, so a pw-dump failure or an unseeded snapshot made them disagree
-- audio routed while the row was drawn dead. capture_device_present is
fail-open now, matching the gate exactly.

pw-dump was called synchronously on the GTK main thread in three places,
including during window construction and from a GLib timeout, each with a five
second timeout. The snapshot is seeded by _do_start on the worker, and
request_capture_poll queues the refresh instead. The device-confirm path relies
on the worker running queued tasks in insertion order, so its refresh still
lands before the reconcile that needs it.
The comparison deciding whether a PipeWire stream belongs to a source was
written twice -- once in mixer._reconcile_app_cell for routing, once in
app._refresh_app_meter for the level display -- so the two could drift and
disagree about the same stream. There is one stream_matches() now, used by
both.

Matching is case-insensitive and whitespace-stripped, and considers the node
name and process binary as well as application.name, because a manually typed
application name (the next feature) will rarely equal application.name
byte-for-byte. Substring matching is deliberately rejected: it would make a
source bound to "Chrome" swallow every Chromium stream.

Relaxed matching makes it possible for two sources to match one stream, which
would route it into the same mix twice at roughly +6 dB. claim_streams()
assigns each stream to exactly one source so that cannot happen.

A source bound to an application that is not currently running now reads as
waiting rather than looking broken.
The Add Source picker listed only applications currently playing audio, so an
app that was not running could not be bound at all, and a source bound to the
wrong thing could not be corrected -- only deleted and recreated, losing its
per-mix levels with it. A free-text Application field now sits on the config
page, and passing source= opens the dialog straight to that page in edit mode,
which is the right shape because the pickers list what is present right now and
requiring the bound app to be playing in order to rename its row is nonsense.

Edits go through sources.update(), never new_source(): the source id is the
prefix of every "<source_id>.<mix_id>" cell key, so minting a fresh one would
orphan every persisted level.

Reconciled against the other three features rather than applied as written --
all four touch app.py, and three touch mixmatrix.py and sourcedialog.py:

- The Application row is suppressed for a capture device. Confirm is gated on
  that row being non-empty, so leaving it in place made the device flow from
  the previous commit impossible to complete: the button could never enable.
- Editing a device source no longer stamps a match_app_name onto it. The
  handler was written before device sources existed and updated the binding
  unconditionally.
- SourceCell's icon is an attribute rather than a local, because set_icon()
  needs it; the two arrived from different features and would have raised
  AttributeError on the first icon edit.
- The picker page's Cancel button is gone. It is no longer the navigation root,
  so NavigationView already supplies Back and the type page carries Cancel.
Every loopback was spawned with PR_SET_PDEATHSIG, so closing OpenWave killed
the loopbacks carrying each mix to hardware. Since the mixes are null sinks and
one of them is normally the system default, that silenced the entire machine,
not just OpenWave -- quitting a mixer window is not supposed to be a
system-wide mute, and the failure gives no clue what happened.

Output loopbacks are now spawned detached: no death signal, their own session.
stop() and the atexit handler skip them for the same reason, so an ordinary
quit does not undo the detach. Cell loopbacks keep the old behaviour, because
they are mixing state rather than an audio path and are rebuilt on the next
start.

_sweep_stale_loopbacks already reclaims them at startup, so they are adopted
rather than duplicated: verified one output loopback after a restart, not two.

Verified on hardware: with OpenWave killed, a tone played into the Personal Mix
still reaches the output loopback at -6.9 dBFS.

This does not cover a PipeWire restart while OpenWave is closed -- nothing
respawns the loopback until the app runs again. Moving ownership into the
already-supervised daemon would close that gap.
Every Gtk.Scale was appended to the sidebar box instead of added to its
PreferencesGroup, so it rendered below the whole card rather than under the row
whose value it sets -- a floating slider with no visible owner, which in the
Microphone group sat between the card and the next one and could plausibly have
belonged to either.

The sidebar drops from five groups to two plus a collapsed expander:

The 'Audio' group existed to hold one row about the capture-fix service. It is
a header-bar button now, hidden entirely while the service is healthy, with the
detail and the uninstall action in its popover.

Device Info -- three read-only fields nobody reads twice -- is an ExpanderRow in
a titleless group, so it costs one collapsed line.

The service warning also stops crying wolf. The capture fix works around a
firmware race between playback and capture on one device; a card with no
playback side cannot hit it, so the warning is suppressed there. That is the
normal configuration for anyone monitoring through a headset rather than the
Wave's own jack, where the old UI warned permanently about a service that had
nothing to do.
…tions

Three things that made a working system look broken.

A mix whose cells are all at zero is silent, and looks identical to a working
one: the sink exists, other applications can select it, and it plays nothing.
That is exactly what happens to a newly created mix, since every cell starts at
zero -- selecting it in Discord produced 'isn't detecting any input' with
nothing in OpenWave to explain why. The column header now says 'No sources
routed' where it would otherwise name an output device, which is the right
place: where a mix routes is moot until something feeds it.

The Add Source picker listed every Stream/Output node, and a loopback's
playback node is one. So 'playback.game_output' was offered as though it were
an application; binding it captures whatever passes through that channel rather
than a program, which is not what the picker appears to promise. Loopback nodes
and virtual nodes with no process binary are filtered out.

The Chat and Record mixes still described themselves as pending '(v0.3.0)'.
They work. Since that text is already persisted in every existing
mixdefs.json, fixing the seed alone would leave it on screen forever, so the
exact original strings are replaced on load -- anything the user has edited
since is left alone.
An application source spawned a loopback capturing the application's stream and
left the application connected to whatever it was already playing into. When
that was one of our own mixes -- which it normally is, since the monitoring mix
is the system default -- the audio arrived twice: once directly, once through
the loopback. The cell's fader could only add a second copy on top of an
untouched original, so pulling it to zero changed nothing audible. It looked
like a broken fader and was really a broken model.

Each application source now owns an intake sink, openwave_src_<id>, and its
streams are moved onto it. The loopback out of that sink is then the only path,
so the fader is authoritative. One loopback per (source, mix) replaces one per
stream, so several streams from one application share a level.

A source can now gather several applications. match_app_names is a list, so a
Music row can hold two players and a Games row every game, each under one
fader; bindings() still reads the older singular match_app_name, so existing
files need no rewriting.

Measured rather than assumed, and two assumptions were wrong:

object.linger is mandatory -- without it a pw-cli-created sink dies the instant
pw-cli exits, so an intake cannot be made to vanish with us. Safety therefore
comes from destroying it: measured, destroying a sink REROUTES its streams to
the default rather than killing them. So the intake is destroyed when the
source is removed, on clean shutdown, and swept at startup if a crash left one
behind. pw-metadata target.object does not move a stream; pactl
move-sink-input, addressed by object.serial, does.

A source that routes nowhere is left alone. Moving a stream onto an intake that
no mix drains would have muted the application outright -- every cell starts at
zero, so that would have been the common case, not an edge one.
The matrix opened as an empty grid: a new user saw the mix columns and the
built-in microphone row, and had to know which application names to type before
anything could be routed. These five rows cover what most people actually mix.

Safe to seed only because a source that routes nowhere is now left alone. Every
cell starts at zero, so a seeded row creates no intake sink and moves no
stream; it is a suggestion until a fader is raised. Verified: with five sources
seeded and one routed, exactly one intake sink exists.

Each row lists several application names, which the previous commit made
possible. Matching is case-insensitive across a stream's application name, node
name and process binary, so one entry covers a program whose reported name
differs from its binary. Checked against the identities on this machine:

  gnome-shell, gsd-media-keys -> System
  steam, RSI Launcher         -> Game     (the latter by name, under wine64-preloader)
  Spotify                     -> Music
  Chromium                    -> Browser
  WEBRTC VoiceEngine          -> Voice    (by binary; Discord reports the WebRTC name)

Each claimed by exactly one row, so nothing is routed twice.

Seeding follows the mix store: written only when sources.json is absent. An
existing but empty file is respected -- a user who deleted every row meant it,
and reseeding would restore them all on the next launch.
One source can now gather several applications, and the seeded rows carry a
dozen names each. As a single comma-separated entry that is unreadable, and
removing one name means editing a string by hand without misplacing a comma.

Each bound application is a row with a remove button, above an entry that adds
one. A 'From running apps' button lists whatever is currently making sound,
minus what is already bound -- the common case is that the application is
running and the user simply does not know what PipeWire calls it, which the old
field answered with an instruction to go and grep pw-dump.

Duplicates are rejected case-insensitively, matching how the names are matched
in the first place. A name still sitting in the entry when Confirm is pressed
is folded in rather than silently discarded, and it counts towards enabling
Confirm, so typing one name and confirming does what it looks like it does.

The dialog still emits one comma-separated string, so nothing downstream
changes: app.py parses it into the list as before.
…s content

Three things the seeded rows made obvious.

Reordering. Row order is the order sources were added, which is nobody's
preferred layout for long. Each row gets Up and Down buttons, greyed at the
ends rather than doing nothing. Gtk.Grid has no row-move, so the rows are torn
down and rebuilt; every MixCell is a new widget afterwards, so the caller
re-wires the cells and the persisted levels come back with them. Out-of-range
moves clamp rather than wrap: a button at the end of the list should do
nothing, not throw the row to the other end.

System becomes a catch-all. An application whose reported name matched no row
went straight to the default sink, bypassing the matrix entirely -- invisible,
and with no fader. The System row now takes whatever no other row claimed. It
is strictly a fallback: an explicit name always wins, and a stream still has
exactly one owner, so nothing is routed twice.

Window size. The default 1100x620 could not display its own contents: the
matrix needs 260 for the source column plus 228 per mix, and the sidebar
another ~340, so three mixes overflowed the width by ~180px, and the height ran
out at the sixth row -- which the five seeded sources plus the microphone
reach immediately. The default is 1360x800 now, and the size is remembered
across sessions, since how big the window should be depends on how many mixes
and sources the user keeps.

Caught while wiring: move-clicked was declared on MixHeaderCell rather than
SourceCell, which emits it, so the first source row raised 'unknown signal
name' on construction.
Reordering is drag and drop. The Up/Down buttons are gone: arranging a list
visually is what dragging is for, and two more buttons in a row that already
had four was the wrong direction. A row shows a drag handle, dims while it is
being dragged, carries its own likeness as the drag icon, and the row under the
cursor gets a drop line. The drop is expressed as a delta through the same path
the buttons used, so ordering, clamping and persistence stay in one place.

The Wave's own microphone row is pinned to the top and cannot be dragged or
removed. It is not part of the user's ordering -- it does not live in the
sources store -- and reorder_sources rebuilds from that store, so without
pinning it the rebuild deleted the row outright.

The source row's own slider did nothing. It was built with has_level=True and
never connected to anything, so it moved and no audio changed. It now sets the
source's overall level on its intake sink, ahead of the per-mix faders: a
channel trim rather than a send. Stored on the source record so it is restored
deterministically rather than relying on WirePlumber having remembered the
sink, and applied when the intake is created so the slider means something the
moment a source is routed.

Source names had vanished. The column was 260px and the row had grown to hold a
drag handle, icon, name, mute, level, meter, edit and remove; the name was the
only flexible part, so it ellipsized to a bare .... The column is 400px and
the label has a width request and a tooltip.

Sliders show their value as a percentage. Fixed width and monospace so a row
does not reflow as the number crosses 9% and 99%.

The sidebar starts closed. The matrix is what the window is for; the device
controls are set once and left.

The gain slider gets a lock. Preamp gain is set once and then wants leaving
alone -- a stray scroll silently changes how loud you are to everyone else, and
nothing on screen makes that obvious afterwards. The lock persists.
The row slider set the volume of the source's intake sink, which does nothing
useful. A null sink's monitor is taken pre-volume, so the loopback reading that
monitor never saw the change -- and measured on hardware it was worse than a
no-op: at sink volume 0 the monitor read -0.0 dBFS against -29.5 dBFS at full,
because the pulse layer raises the stream to compensate for a sink turned down.
Setting the fader to zero made the source louder.

Adding monitor.channel-volumes to the sink did not fix it either; the
compensation still won. So the trim is applied where attenuation is already
known to work: it multiplies into the per-mix loopback volume, the same control
the mix cells use and the one measured to attenuate.

The two sliders now compose as a trim and a send. The row slider scales that
source everywhere, the cell decides how much of it each mix receives, and the
loopback carries their product: a 0.60 cell under a 0.25 trim reports 0.16, and
a zero trim reports 0.00.

monitor.channel-volumes is still set on sinks created through pw-cli, so they
match the ones the generated config makes rather than differing by accident.
A microphone row showed a speaker for its mute button, which reads as "this
output is silenced" rather than "this microphone is off" -- wrong for the
Wave's own row and for a headset added as a capture source. Capture rows use
the microphone icons; application rows keep the speaker ones, which are right
for them.

A muted row was distinguishable only by one small icon changing shape. It now
tints red across the row, so a muted source is obvious scanning down the
column rather than something to hunt for -- which matters because a muted row
is otherwise indistinguishable from one whose application is simply quiet.

The mute button also states which way it goes, since a mute toggle that shows
the current state and the action in the same icon is ambiguous either way.
The source rows gained a red tint for mute in the previous commit; the mix
cells beside them did not, so the same state looked different depending on
which column it was in.

A cell at zero and a muted cell mean the same thing, and letting them disagree
produces either a slider sitting at 0% next to an unmuted icon, or a slider the
user raises with no sound because a mute they had forgotten is still on. They
are now coupled in both directions: dropping to 0% mutes, raising off zero
unmutes.

A new cell starts muted, because it starts at zero and routes nothing. Leaving
it unmuted showed an armed-looking control carrying no audio -- which is the
same confusion the empty-mix indicator exists to solve, one level down.

set_volume and set_muted still restore saved state without emitting, so wiring
a row does not fire a mute storm or write back the state it just read.

Caught while writing: the shared _reflect_mute ended up calling itself after a
regex replaced its own body along with its two call sites, which would have
recursed on the first mute.
The project had no tests and no CI beyond a manual release workflow, and the
README described a version of the app that no longer exists: no MK.2, no mixes,
no sources, and an architecture list missing nine modules.

71 tests over the parts where a regression is silent rather than loud: which
stream belongs to which source, the JSON stores, state migration, output
resolution, the trim-and-send arithmetic, the generated PipeWire config, and
the firmware-to-ALSA scaling. Each one states what breaks if it fails, because
a test named after the function it calls is worth very little six months later.

stdlib unittest, not pytest. The project has no development dependencies and
this adds none, so the suite runs on a bare checkout.

The backend imports neither GTK nor a running PipeWire, so the suite needs no
display, no audio server and no hardware -- verified by running it with the gi
module blocked. libusb is the one system dependency, because device.py loads it
through ctypes at import.

Tests point the three JSON stores at a temporary directory. Their paths are
module-level constants, so a test that forgot would read and overwrite the
user's real configuration; the isolation is checked by comparing checksums of
~/.config/openwave before and after a run.

docs/ARCHITECTURE.md covers what the code cannot say for itself: why an
application's audio is moved rather than copied, why the trim is not the intake
sink's own volume, why every stream needs exactly one owner, and what each
node-name pattern means.
A mix could not reliably be used as a microphone in a voice application.

Two problems. A mix's monitor already carries its audio, but Discord and others
filter monitor sources out of their input lists, so selecting a mix that way is
impossible -- OpenWave's own config comments tell the user to pick "Monitor of
OpenWave Chat Mix", which that user cannot see. Each mix is now also published
as an ordinary capture source, <sink>_source, which every application lists.

The second problem is why this broke after working. That source was previously
a hand-written PipeWire config whose loopback resolved its target once, at
PipeWire start. Installing mixes destroys and recreates their sinks, and the
new sink is a different node, so the loopback carried on against a dead link:
the source still existed, was still selectable, and was silent. Found on this
machine with both the chat and record mixes in exactly that state, and the
symptom is "Discord cannot hear me" with nothing visible to explain it.

So the link is re-asserted on every reconcile rather than trusted once.
Verified by cutting a live link and watching the next pass restore it.

The published name is unchanged from the config it replaces, so an application
that has already selected the source keeps working. The startup sweep now
matches openwave_ rather than openwave_loop_, since these are named after their
sink and would otherwise leak one per unclean exit.
A source's intake sink is internal plumbing, but it is an ordinary sink as far
as the session manager is concerned, so it can win the default-sink election.
Observed here after a PipeWire restart: the mix sinks were not yet present when
the election ran, openwave_src_system won it, and every application landed in a
single source row at that row's send level. Audio did not stop -- it went quiet
and arrived in the wrong place, which presents as "I cannot hear anything"
with nothing obviously broken.

Intake sinks are created with priority.session=0 so they lose that election to
anything else, including every mix. That makes it unlikely.

_rescue_default_sink makes it recoverable: if the default is one of ours, it is
moved to the first mix at startup. A sink that exists only to be read by a
loopback should never be where the system sends its audio, and the failure is
invisible enough to be worth repairing rather than merely discouraging.

Also rewrites the null-sink property list as a joined list rather than implicit
string concatenation. Adding a conditional property to the old form produced a
syntax error, which is a sign the construction was too clever for what it does.
The two preceding commits changed behaviour that nothing exercised: the
properties a null sink is created with, and the repair that moves the system
default off an intake sink.

Twelve tests. The sink-property ones assert the three that are silent when
wrong -- object.linger, without which the node dies as pw-cli exits;
monitor.channel-volumes, without which a level applied to the sink does nothing
downstream; and priority.session, present only for intakes so a mix stays
eligible to be the default. The rescue ones cover both directions, including
the cases where it must do nothing: a hardware default, a mix default, and no
mix to move to.

Both are mocked at the subprocess boundary, so the suite still needs no audio
server and cannot disturb the running system.

docs/ARCHITECTURE.md gains the default-sink hazard, which is not inferable from
the code: an intake sink is internal but is an ordinary sink to the session
manager, and losing that election quietly is the difference between audio that
stops and audio that arrives somewhere unexpected at the wrong level.
Closes the ask in issue rikkichy#7: the nodes OpenWave spawns were unidentifiable, so
filtering them out of a script or picking them out in a mixer meant matching on
node.name patterns and hoping. Of 30 nodes, 22 published node.description
values like "pw-loopback-542152" and none set application.name at all.

Every loopback now carries application.name=OpenWave and a description naming
what it actually is -- "Music -> Personal Mix", "Microphone -> Chat Mix",
"Personal Mix -> output" -- on both halves, with the capture side marked so
the two are distinguishable. The pw-cat level meters, which the issue names
explicitly, carry a stable node.name of their own rather than appearing as bare
"pw-cat" entries.

Verified against the live graph: 34 nodes, none left with a machine-generated
description.
Closes the ask in issue rikkichy#1, open since May with no solution. Elgato's answer
is to hold the dial for two seconds, which cannot be scripted and cannot be
done at all on an XLR Dock -- that variant has no controls whatsoever, so
before this there was no way to power a condenser microphone on it from Linux.

Found by watching the whole config block on a Wave XLR while the dial was
held: byte 6 flipped 0x01 -> 0x00 and back, in step with the 48V LED, and
nothing else in the mapped region moved. Writing it was then confirmed against
the LED, so it is a control rather than a status mirror the firmware maintains.
Neighbouring bytes 5 and 7 also read 0x01 but did not move and are untouched;
gain, mute and low impedance were verified intact across the write.

Exposed as a switch in the Microphone group, hidden on a device with no XLR
input -- the Wave:3 is a microphone and has nothing to power.

Two notes for anyone extending this. The dial-hold gesture is documented for
the Wave XLR only; the XLR Dock has no dial, which is what makes a software
toggle necessary rather than merely convenient. And 'MK.2' covers at least two
product ids: 0fd9:00a6, which this was verified against, and 0fd9:00c7 from
issue rikkichy#6, which nobody has confirmed.
NyleGarcia and others added 27 commits August 30, 2026 15:19
python3 -m wavexlr.diag writes a single text file: versions, every
device's profile and config hexdump, udev and service state, the daemon
journal tail, and OpenWave's PipeWire nodes. Every collector is
isolated — the bundle a reporter attaches when something is broken must
survive everything being broken — and config contents stay out unless
--full, because running application names are personal. The firmware
serves one process at a time, so the CLI explains itself when the app
holds the handles; the in-app export reads through them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
scenes.py stores named snapshots — trims, sends, mutes, outputs, mix
masters, hardware state keyed by profile:serial so two units of one
model stay distinct. Mixer.scene_state() captures live values (whatever
moved a level, that is what the scene should hold) and apply_scene()
sets levels through the normal entry points so reconcile stays law.
Partial apply is normal: a scene naming a source or mix since removed
sets what still matches and reports the rest, and never restructures the
matrix. Mixer.set_mix_volume() gives the UI the same master write path
an external mover takes. Not named "profiles" — that word belongs to the
USB protocol constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
The app-side half of the sprint, entangled by design because the window
owns all the state:

Every connected Wave is held, polled and ALSA-synced at once; a Device
dropdown appears with two or more, a sysfs watch catches units appearing
or vanishing while others stay connected, and losing the selected unit
falls back to a survivor instead of disconnecting. Reconnects cannot
stack, poll ticks cannot pile onto a dying handle, and discovery reruns
on the device sweep so a replugged Wave gets its row back without a
restart.

Scenes get a header-bar menu and four bus actions; the diagnostics
export gets its sidebar row, reading through the handles this process
holds. Mix headers gain a master slider (throttled, following external
movers within a tick) and a level bar tapping the mix's own monitor via
stream.capture.sink — without it the session manager linked the meter to
the default microphone. The bar displays amplitude on the same cubic
taper the faders use, with peak-hold ballistics, at 64 ms windows so
seven meters stop costing four hundred main-loop wakeups a second.

Row mute and hardware mute become one mute: rows pair with handles by
serial (stem fallback when devinfo will not read), a row mute drives the
firmware from any path including group hand-overs, and the physical
button or a system-side mute reaches the row — change-driven in both
directions, so the pair cannot loop. An unplugged device's row grows a
remove button, and removing it re-arms the auto-offer for its return.

The window also stops opening at its own minimum size on first run, and
the matrix gets the bottom margin its other sides had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
A manifest under packaging/flatpak/, honest about the boundary: raw
libusb needs --device=all with USB permission still coming from host
udev rules the sandbox cannot install, first-run setup and the
capture-fix daemon stay native, and the pw-*/wpctl/amixer tools the
mixer shells out to are bundled so they exist inside. Sources are pinned
by git tag and commit; the module trio (alsa-utils, pipewire tools,
wireplumber) is what the subprocess seams require. Untested against a
real build yet — the runtimes are a multi-gigabyte download — and marked
experimental in the README accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
A capture node that reappears — a replug, a recovery card-cycle — is a
new node wearing the old name. The cells' loopbacks are spawned with
autoconnect off and hand-linked, so they survived the corpse: alive,
healthy by every check the reconcile makes, and carrying nothing, which
reads as "my microphone stopped working" with no visible cause and no
remedy short of restarting the app. Diagnosed live against an XLR Dock
that came back from a replug and again from a card-cycle with its
loopbacks pointing at the dead node both times.

The capture poll now tears down the returning node's cell loopbacks
before reconciling, so the pass that follows respawns and relinks them
against the reincarnation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
An immutable /usr changes what "install" means, and the honest answer
for OpenWave is that it barely matters: there is no build step, so the
checkout is the install, the udev half of first-run setup writes to the
still-mutable /etc, and the capture-fix service is a user unit. The
guide says which packages may need layering on which image, why make
install's SITEPKG half fails on OSTree, why a distrobox is the wrong
container for something that needs the host's graph and USB, and gives
the Flatpak path its manual udev step. Untested on a real Atomic system
yet, and says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
The URL was pinned to the v1.1.0 tag because the fork's main predated
the screenshot; main has been fast-forwarded and carries it, so the
image can track the branch instead of a frozen tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
A noarch spec built by release.yml from the release tarball. The module
tree goes under /usr/share/openwave with PYTHONPATH launchers rather
than into %{python3_sitelib}: the rpm is built on the release runner,
not on Fedora, and a noarch package hardcoding one Fedora release's
python3.X path would break on the next — the same shape, for the same
reason, as the Nix package. Fedora's Atomic descendants still want the
Flatpak or the checkout (docs/install-bazzite.md); this serves classic
Fedora and openSUSE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
A dispatch dry-run versions itself 0.0.0-dev.<sha>, and rpm forbids '-'
in Version. The spec now takes the rpm-safe form (dashes become ~, rpm's
own pre-release marker) separately from the tarball's version string,
which names Source0 and the unpack directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Seven build iterations against the actual builder, each finding a real
hole: the GNOME runtime ships no libusb (bundled, v1.0.29); alsa-utils'
git tree has no autogen.sh and insists on a libasound its own age
(alsa-lib built first); its udev rules aim outside /app; wireplumber
wants Lua the runtime lacks and offline builds cannot wrap-download
(Lua 5.4.8 bundled with a hand-written .pc, system-lua=true) and spells
its tests option as a boolean. The runtime moves to GNOME 49 — 48 went
EOL in March — and the reserved /proc/asound share is dropped.

setup.py learns it can be sandboxed: udev_installed() stops re-prompting
for a setup that cannot run, and run_setup() explains the one-time host
udev step instead of crashing into a pkexec that does not exist.

Smoke-tested: the sandboxed app boots and serves its full D-Bus surface
in three seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
The DSP plan grows tiers with a build order: builtin biquad low cut
first (zero dependencies, proves the whole insertion), the LADSPA
classics second, RNNoise noise removal third — verified against
PipeWire's own filter-chain docs, CPU-only, GPL, 48 kHz like the graph.
NVIDIA Maxine/Broadcast becomes a research track with the four questions
that must be answered before any code: Linux SDK availability and
license, the RTX+TensorRT wall, the absence of any PipeWire story, and
whether it audibly beats RNNoise on real recordings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Mic side: presence EQ, mono downmix and per-source delay join the
zero-dependency tier (all builtins); a de-esser and an auto-leveler join
the LADSPA tier with their candidate plugins named for verification at
build. Mix side gets its own insertion point: per-mix headphone EQ on
the output path, an EBU R128 loudness readout for the Record/Stream mix
(48 kHz tap, spawned only while visible), and music ducking — planned as
an app-driven control loop over the existing 15 Hz meters and throttled
cell writes rather than a sidechain plugin, which filter-chain's
single-capture model likely cannot host anyway. A build order says what
proves what.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Low cut at 80 or 120 Hz, a three-band presence EQ, up to half a second
of alignment delay, and forced mono — the zero-dependency tiers of the
DSP plan, end to end. The chain is one filter-chain graph hosted by a
`pipewire -c <generated conf>` child (a module has to live in some
process, and a config-owned child obeys the same lifecycle rules as
every pw-loopback here), publishing a virtual Source the row's cells
capture instead of the raw device. Neutral settings hold no process;
a settings change respawns the chain rather than growing a second,
drift-prone parameter-patching path; the replug teardown covers the
chain the same way it covers the cells. The generated config carries the
stock filter-chain module preamble, without which a bare `pipewire -c`
context cannot even reach the daemon — found the hard way.

Settings live on the source record, edited from a per-row popover,
debounced so an EQ drag is one respawn, not forty. Verified against
real hardware: a 120 Hz low cut measured 10.2 dB of relative low-band
removal at the chain's published Source while the raw node kept its
bass, and cells followed the chain on, and the raw device off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
The first LADSPA tier: swh-plugins' gate (threshold exposed; musical
attack/hold/decay fixed) and mono sc4 compressor (threshold and ratio
exposed) slot between the low cut and the tone EQ — channel-strip
order, gate the raw dynamics, compress what survives.

A missing plugin library is a first-class state, found by testing on a
machine without it: the chain's process dies on load, and the fx pass
turns that into one warning naming the package and a fallback to the raw
device, never a respawn loop — including when _reap_dead collects the
corpse before the pass looks, which read as "never spawned" and looped
slowly. A quiet graph gets the same detection from a health check on the
stream tick, so the warning lands within seconds, not at the next
unrelated stream event. A settings change is consent to try again.

Control names follow swh's documentation and want verifying with
analyseplugin against the installed library before the thresholds are
trusted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Two findings from running the chain against the installed swh-plugins
rather than their documentation: LADSPA nodes expose the library's own
audio port names (Input/Output, where builtins say In/Out), and the
gate's output-select CONTROL port's name includes its whole legend.
Either mismatch is fatal to the graph, not a warning — the link renderer
now picks port names per node type, and both control sets were dumped
from the .so files via ctypes rather than trusted from docs.

Measured on hardware with the full strip live: a −62.6 dBFS noise floor
gated at −35 dB leaves the chain at −140 dBFS — the gate closes to its
full range — and the compressor loads and links cleanly behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Toggling effects on made a chain, published its Source — and changed
nothing audible, because the cells' loopbacks predate the chain and
their links are made once at spawn. An existing process is an existing
ROUTE: the reconcile now remembers what each cell drinks from and
rebuilds the loopback when the wanted source differs, so fx toggling
retargets live in both directions. Found on hardware by a user hearing
no difference while every piece reported healthy; the original test
spawned the chain before the cells and could not see it.

The popover also stops offering inert sliders: threshold and ratio dim
while their effect is off, and dragging one flips the effect on —
choosing a threshold IS enabling, and the previous arrangement shipped
settings with every switch still off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Manual capture linking exists for one reason: a null sink's monitor
cannot be an autoconnect target, so the mix-fed loopbacks must be wired
by hand. Device cells inherited that machinery although their targets —
hardware capture nodes, fx chains' published Sources — resolve fine,
and hand-made links die silently with their node and stay dead: every
fx toggle rebuilt the loopback into a race against the fresh node's
ports, which is exactly "it stopped routing when I touched the gate".

Capture cells now pass target.object and let WirePlumber make, keep and
repair the link, mono negotiated end to end. The manual path remains
for what actually needs it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
An "Auto-calibrate" button in the effects popover runs two guided
measurements off the RAW device node — a silent stretch for the noise
floor, a spoken one for the voice — reduced to per-window peak levels
and turned into settings by the rules an engineer would use: the gate
sits above the floor with margin but below the quietest voiced material
with more (words always win the argument), the compressor rides under
the loudest peaks at 3:1. The wizard reports what it heard in dBFS, a
measurement without clear speech explains itself rather than emitting a
threshold computed from silence, and the analysis is pure and tested
apart from the capture. pw-cat records until killed, so the capture
reads exactly its byte budget and stops the child itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
The same two captures now judge more than levels, through octave-coarse
energies from first-order filters (pure python — numpy is not a
dependency this project has, and coarse decisions need coarse bands):
the low cut goes to 120 Hz only when the floor is rumble-heavy AND the
voice has no fundamentals in the 90–180 octave to thin — a deep voice
vetoes the room; the high shelf nudges halfway toward a normal speech
tilt, clamped to ±4 dB so a wild measurement cannot order a wild EQ;
and a capture with one silent channel suggests forced mono. Alignment
delay stays manual — it needs a reference no microphone measurement
carries. Capture is stereo now so balance is measurable; a mono device
simply delivers two equal channels.

Verified against the room: the headset's floor measured with almost all
its energy below 90 Hz — precisely the rumble case the higher cut
exists for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
The effects button wore an icon Breeze does not ship — the broken-image
box, on the very rows the icon-resilience layer exists for — and is now
the label "FX", which every theme renders and reads clearer anyway.

The columns stop zigzagging: optional controls (the group hand-over
button, the remove button, FX on non-capture rows) used to vanish via
set_visible, collapsing their slot and shifting every widget beside
them by a button-width per row. They are now blanked in place — opacity
zero, insensitive, untargetable — so sliders, percentages, meters and
buttons rail up across every row regardless of which options a row has.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Nine level bars at fifteen frames a second were a hundred-plus main-loop
wakeups and redraws every second — during silence, and with the window
hidden in the tray, where they render for nobody. Two rests: a quiet
meter dispatches a short tail (so peak-hold ballistics animate down),
sends one final zero and goes idle until signal returns; and an unmapped
window suspends dispatch entirely. The readers keep draining either
way — the byte-flow stall detection lives on the bytes, not the bars.
Discovery also stops running its pw-dump on a six-second main-thread
tick; a device's USB connect event triggers it instead, which is when a
row could actually appear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Three additions a control surface asked for, prompted by studying
openxlr's OpenDeck plugin:

Stateful actions now emit real Changed signals: every path that moves
the mixer — a slider here, the hardware mute button, a scene recall, a
deck key — refreshes the published snapshot, scenes and source-groups
states through one 150 ms debounce, so a subscriber redraws the moment
things move instead of on a timer. The activate-then-describe poll
contract still holds for clients that prefer it.

A `levels` state publishes every live meter's latest peak, fed by the
same callbacks that move the bars — deliberately poll-only, because
fifteen broadcasts a second per meter serves nobody; a remote reads it
only while a meter-bearing control is on screen.

`toggle-fx` (source, effect) flips the toggles a deck key can honestly
draw as an LED — lowcut, gate, comp, mono — and the snapshot carries
each source's fx settings so that LED needs no second call. Thresholds
are not toggles and stay with the popover and the calibrator.

Verified over the bus: two mute toggles produced exactly two Changed
broadcasts, levels published seven meters, toggle-fx round-tripped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb
Two halves of the robotic-mic story. Root cause: every ALSA capture
node defaults priority.driver=2100 and ties fall to lowest object id,
which handed the graph clock to a wireless dongle whose jitter made the
Wave's follower DLL resync ~23x/s. Pin priority.driver=2500 so the
Wave's wired iso clock drives.

Safety net: HealthMonitor in the daemon with two slow watchdogs — a
glitch watch on pw-top xrun accumulation (cycles the card) and a sink
stall watch on frozen kernel hw_ptr (suspend/resume toggle). Both
rate-limited: 2 attempts, 60s cooldown, budget refills on recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H
A capture device's own ALSA-level mute (a headset's hardware button, a
toggle in another mixer) now syncs with its matrix row both ways. Row
mute mirrors to the source via pactl — the same mirror open Waves get
over USB — and the ~6s capture poll follows external changes into the
row, on edges only so a stale snapshot can never undo a fresh click.
At first sight the row wins: a group hand-over's muted backup stays
muted, and a stale device mute left by a session-manager restart (the
"mic isn't working" trap) is cleared to match the live row. The
capture-stall watchdog now also knows a muted microphone is silent on
purpose and leaves its card alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H
The refill-on-one-clean-window loop, observed live: a card cycle buys
a quiet window while the capture reopens, the budget refills, and a
persistent fault becomes an audible pop every two minutes (three
cycles in four, each leaking a duplicate sink node in WirePlumber).
Budgets now refill only after sustained quiet — 5 min for the glitch
watch, 1 min of movement for the stall watch — and a post-cycle
counter reset counts for nothing.

The glitch watch also ignores muted captures: muted delivers one
inaudible xrun per graph cycle forever, which crosses the threshold at
small quanta, and cycling the card of a deliberately muted microphone
only blinks everyone else's audio.

Logging says each thing once with numbers: confirmation with the
measured delta, remedies with attempt counts, giving up with when the
watchdog re-arms, and the re-arm itself. Plus docs/troubleshooting.md
with the field notes and diagnosis method behind all of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H
@Sharpienero

Copy link
Copy Markdown
Contributor

I'm running this branch locally and while it does have a very nice feature set, the UI is a bit buggy. I've experienced several crashes in the UI when playing with some of the features, like the FX's "auto detect settings" feature and a crash when moving the window around. Aside from that, the feature set is rich and the design is nice as well.

I am runnnig cachy with niri and noctalia with a wave xlr + shure sm7b.

@NyleGarcia

Copy link
Copy Markdown
Contributor Author

If you notice anything specific please let me know ill work on fixing them
Im running cachy + Wayland so might not see some of the issue.

Two crashes reported on niri (CachyOS, Wave XLR + SM7B): one on the FX
popover's auto-calibrate, one on moving the window around.

Nothing that shells out belongs on the GTK thread, and most of it was
there. The 2 s stream tick ran pw-dump (poll_streams) plus two pactl
passes (restore/observe mix volumes) inline, the capture-stall path
could spend 15 s of pactl in a card cycle, meter refreshes waited up to
2 s per meter on terminate/kill, and output refresh, source install and
Elgato discovery each paid their own pw-dump. A main loop parked in
waitpid is a main loop not reading its Wayland socket, and a scrolling
tiler moving a window emits configure, enter/leave and frame events fast
enough to fill that socket and have the compositor cut the client — the
window vanishing mid-drag with no traceback and no core to show for it.
The mixer grows request_stream_poll and request_volume_sync beside the
capture poll that already had this treatment; the tick now only reads
the snapshots they leave behind.

Signals that dismantle their own emitter are deferred by one main-loop
iteration: the auto-calibrate button (which opened a dialog in the same
frame as its popover's teardown — a grab moving between a dying
xdg_popup and a new one), the row drop handler and the row move buttons
(both of which reorder, destroying the widget GTK is still emitting
from, while a WidgetPaintable paints it).

Calibration can now be stopped. The capture read was a blocking read on
a pipe, so a node that went away mid-measure — unplugged, suspended —
parked the worker forever behind a modal that could never be dismissed;
Cancel only set a flag read between the two captures. Reads are polled
with a deadline, Cancel terminates the child, pw-cat gets the pdeathsig
every other child in the tree has, and the child is reaped rather than
merely signalled. One calibration per row at a time, the result lands on
the row as it exists now rather than the one captured ten seconds ago,
and it pushes to the remote surface like every other fx write.

Teardown: every timer the window owns is disarmed on shutdown (only the
USB poll was), in-flight workers are joined before the USB handle
closes, and the fx debounce re-resolves its row instead of writing a
destroyed widget's stale values back over a calibration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMZt7J2961pMWSg2e1qjGp
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.

2 participants