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
Open
Wave XLR MK.2, 48 V phantom power, and a mixing matrix with per-mix outputs#8NyleGarcia wants to merge 100 commits into
NyleGarcia wants to merge 100 commits into
Conversation
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.
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
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. |
Contributor
Author
|
If you notice anything specific please let me know ill work on fixing them |
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
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.
Wave XLR MK.2 support, 48 V phantom power, and a mixing matrix with per-mix outputs.
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
mainand 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:
org.gtk.Actions.Changed, so a Stream Deck redraws the moment the mixer moves instead of polling.hw_ptrfroze (silent output). Rate-limited, and they give up loudly rather than loop.docs/troubleshooting.mdwith the field notes behind the watchdogs.The test suite grew with it: 375 cases, still stdlib-only.
Issues
Hardware
MK.2 / XLR Dock (
0fd9:00a6). It speaks the same vendor protocol as the Wave XLR, so the profile is areplace()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,
0x01on /0x00off. Found by diffing the block across a toggle and confirmed against the device's own +48 V indicator lighting up. Capability-gated viahas_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*/usbidrather 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.mdexplains 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_streamsgives 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-volumesdid 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=0so 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
GApplicationalready exportsorg.gtk.Actionsoncom.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 emitorg.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:
Mixerholds the same dict the window holds and rewritessources.jsonwhole 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 byprofile:serialso 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'shw_ptrfroze 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.driveron 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
unittestcases 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.ymldoes 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.