From e7571aa0da7c6b5948064373432bb3859f3a19f9 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 14:59:32 -0500 Subject: [PATCH 01/99] Add Wave XLR MK.2 (0fd9:00a6) as a supported device 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. --- wavexlr/profiles.py | 16 ++++++++++++++-- wavexlr/setup.py | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/wavexlr/profiles.py b/wavexlr/profiles.py index 11a543a..499d013 100644 --- a/wavexlr/profiles.py +++ b/wavexlr/profiles.py @@ -3,7 +3,7 @@ Config offsets set to None mean the device lacks that feature. """ -from dataclasses import dataclass +from dataclasses import dataclass, replace @dataclass(frozen=True) @@ -118,4 +118,16 @@ def has_monitor_mix(self): sync_alsa_gain=True, ) -PROFILES = (WAVE_XLR, WAVE3) +# The Wave XLR MK.2 enumerates as "Elgato XLR Dock" under a different product +# id, but speaks the original Wave XLR's vendor protocol: a probe dump against +# hardware decodes gain @0, mute @4, HP volume @9 and low-Z @33 as expected, +# and the serial lands at offset 27 matching the ALSA card serial. +WAVE_XLR_MK2 = replace( + WAVE_XLR, + key="wave_xlr_mk2", + display_name="Wave XLR MK.2", + pid=0x00A6, + card_match=("XLR Dock", "Wave XLR", "Elgato"), +) + +PROFILES = (WAVE_XLR, WAVE_XLR_MK2, WAVE3) diff --git a/wavexlr/setup.py b/wavexlr/setup.py index a6384eb..694486a 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -7,6 +7,7 @@ UDEV_RULES = ( 'SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="007d", MODE="0666"', # Wave XLR + 'SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="00a6", MODE="0666"', # Wave XLR MK.2 'SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="0070", MODE="0666"', # Wave:3 ) UDEV_PATH = "/etc/udev/rules.d/99-openwave.rules" @@ -97,6 +98,7 @@ def install_udev(): udevadm control --reload-rules udevadm trigger --subsystem-match=usb --attr-match=idVendor=0fd9 --attr-match=idProduct=007d udevadm trigger --subsystem-match=usb --attr-match=idVendor=0fd9 --attr-match=idProduct=0070 +udevadm trigger --subsystem-match=usb --attr-match=idVendor=0fd9 --attr-match=idProduct=00a6 # Also chmod the device node directly so no replug is needed for dev in /dev/bus/usb/*/; do for f in "$dev"*; do From c008fe270d0e79c131b82c4f14b35eeb6309c38c Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:00:33 -0500 Subject: [PATCH 02/99] Match the MK.2's card name when resolving mic and headphone nodes 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. --- wavexlr/mixer.py | 14 ++++++++++++-- wireplumber/51-openwave-wave-xlr.conf | 7 ++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 6e58ae6..d740701 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -63,16 +63,26 @@ def _pactl_short(kind): return [line.split("\t") for line in r.stdout.splitlines() if line.strip()] +# ALSA node-name fragments that identify a Wave device. The MK.2 enumerates as +# "Elgato XLR Dock" rather than "Elgato Wave ...", so matching only the latter +# misses it entirely and leaves both mic and hp unresolved. +CARD_NAME_TOKENS = ("Elgato_Wave_", "Elgato_XLR_Dock") + + +def _is_wave_card(node_name): + return any(token in node_name for token in CARD_NAME_TOKENS) + + def find_wave_xlr_alsa(): """Return (mic_node_name, hp_node_name); either may be None if unplugged.""" mic = next( (p[1] for p in _pactl_short("sources") - if len(p) > 1 and p[1].startswith("alsa_input") and "Elgato_Wave_" in p[1]), + if len(p) > 1 and p[1].startswith("alsa_input") and _is_wave_card(p[1])), None, ) hp = next( (p[1] for p in _pactl_short("sinks") - if len(p) > 1 and p[1].startswith("alsa_output") and "Elgato_Wave_" in p[1]), + if len(p) > 1 and p[1].startswith("alsa_output") and _is_wave_card(p[1])), None, ) return mic, hp diff --git a/wireplumber/51-openwave-wave-xlr.conf b/wireplumber/51-openwave-wave-xlr.conf index 6d4f370..ecf80d2 100644 --- a/wireplumber/51-openwave-wave-xlr.conf +++ b/wireplumber/51-openwave-wave-xlr.conf @@ -1,4 +1,5 @@ -# OpenWave — Elgato Wave XLR (0fd9:007d) and Wave:3 (0fd9:0070). +# OpenWave — Elgato Wave XLR (0fd9:007d), Wave XLR MK.2 (0fd9:00a6) +# and Wave:3 (0fd9:0070). # # UAC1 devices: capture and playback share one iso clock. Format/rate # renegotiation tears down both directions briefly, and is one of the @@ -21,8 +22,8 @@ monitor.alsa.rules = [ { matches = [ - { node.name = "~alsa_input.usb-Elgato_Systems_Elgato_Wave_.*" } - { node.name = "~alsa_output.usb-Elgato_Systems_Elgato_Wave_.*" } + { node.name = "~alsa_input.usb-Elgato_Systems_Elgato_(Wave|XLR_Dock)_.*" } + { node.name = "~alsa_output.usb-Elgato_Systems_Elgato_(Wave|XLR_Dock)_.*" } ] actions = { update-props = { From ea5886987f84a4fee7fae02f9025d8b7a46f1057 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:03:23 -0500 Subject: [PATCH 03/99] Route the Personal Mix to a resolvable output instead of only the Wave's jack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 '.'). --- wavexlr/app.py | 63 +++++++++++++++++++++++- wavexlr/mixer.py | 123 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index ff8791e..10f77b9 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -12,7 +12,7 @@ from .device import WaveDevice from .meter import MeterMonitor -from .mixer import Mixer +from .mixer import Mixer, list_output_sinks, OUTPUT_AUTO from .mixmatrix import MixMatrix from .sourcedialog import AddSourceDialog from . import paths, setup, service, sources as sources_module @@ -45,6 +45,7 @@ def __init__(self, **kwargs): self.mixer = Mixer() self.mixer.set_sources(self._sources) self.mixer.start() + self._refresh_outputs() self.meter = MeterMonitor() self._meter_targets = {} self._wire_matrix_cells() @@ -254,6 +255,22 @@ def _build_device_pane(self, parent): self.mix_scale.connect("value-changed", self._on_mix_changed) parent.append(self.mix_scale) + # --- Personal Mix output --- + out_group = Adw.PreferencesGroup( + title="Personal Mix Output", + description="Where the Personal Mix is played back", + ) + parent.append(out_group) + + self._updating_outputs = False + self._output_names = [] + self.output_row = Adw.ComboRow(title="Device") + self.output_model = Gtk.StringList() + self.output_row.set_model(self.output_model) + self.output_row.connect("notify::selected", self._on_output_changed) + out_group.add(self.output_row) + # Populated in __init__ once the Mixer exists; _build_ui() runs first. + # --- Device info --- info_group = Adw.PreferencesGroup(title="Device Info") parent.append(info_group) @@ -468,6 +485,50 @@ def _send_hp(self, db): self._usb_async(lambda: self.dev.set_hp_volume_db(db), on_error=self._on_usb_error) return False + def _refresh_outputs(self): + """Rebuild the output picker from the live sink list.""" + sinks = list_output_sinks() + current = self.mixer.get_output() + resolved = self.mixer.resolve_output() + + auto_label = "Automatic" + if resolved: + desc = next( + (s["description"] for s in sinks if s["name"] == resolved), None, + ) + if desc: + auto_label = f"Automatic \u2014 {desc}" + + self._updating_outputs = True + try: + self.output_model.splice(0, self.output_model.get_n_items(), None) + self._output_names = [OUTPUT_AUTO] + self.output_model.append(auto_label) + for sink in sinks: + self.output_model.append(sink["description"]) + self._output_names.append(sink["name"]) + try: + index = self._output_names.index(current) + except ValueError: + index = 0 + self.output_row.set_selected(index) + finally: + self._updating_outputs = False + + def _on_output_changed(self, row, _param): + if self._updating_outputs: + return + index = row.get_selected() + if not 0 <= index < len(self._output_names): + return + self.mixer.set_output(self._output_names[index]) + # Re-label "Automatic" once the mixer has resolved the new target. + GLib.timeout_add(400, self._refresh_outputs_once) + + def _refresh_outputs_once(self): + self._refresh_outputs() + return GLib.SOURCE_REMOVE + def _on_lowz_changed(self, row, _pspec): if self._updating_ui or not self.dev.connected: return diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index d740701..8b1aa03 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -51,6 +51,11 @@ def _set_pdeathsig(): HP_LOOPBACK_KEY = "_personal_to_hp" HP_LOOPBACK_NODE = "openwave_loop_personal_to_hp" +# Reserved key in mixes.json holding the Personal Mix's output device. Cell +# keys are always ".", so a bare word cannot collide with one. +OUTPUT_STATE_KEY = "output" +OUTPUT_AUTO = "auto" + def _pactl_short(kind): try: @@ -140,6 +145,60 @@ def _ports(direction_flag, node_name): return [line.strip() for line in r.stdout.splitlines() if line.strip().startswith(prefix)] +def list_output_sinks(): + """Return [{name, description}, ...] of sinks the Personal Mix may feed. + + Only sinks backed by a real device are eligible. Virtual sinks are + excluded because routing the mix into one risks a feedback loop, and not + only via our own mix sinks: a user's per-application virtual sinks + typically feed *into* the Personal Mix, so selecting one would close a + cycle. A hardware sink is a terminus and cannot. `device.id` is the + discriminator — null sinks and loopback sinks do not carry one. + """ + import json as _json + try: + r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5) + if r.returncode != 0: + return [] + objects = _json.loads(r.stdout) + except (FileNotFoundError, subprocess.SubprocessError, _json.JSONDecodeError): + return [] + + out = [] + for obj in objects: + if obj.get("type") != "PipeWire:Interface:Node": + continue + props = (obj.get("info") or {}).get("props") or {} + if props.get("media.class") != "Audio/Sink": + continue + if props.get("device.id") is None: + continue + name = props.get("node.name", "") + if not name: + continue + try: + priority = int(props.get("priority.session", 0)) + except (TypeError, ValueError): + priority = 0 + out.append({ + "name": name, + "description": props.get("node.description") or name, + "priority": priority, + }) + out.sort(key=lambda sink: sink["description"].lower()) + return out + + +def _default_sink_name(): + try: + r = subprocess.run( + ["pactl", "get-default-sink"], capture_output=True, text=True, timeout=3, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return None + return r.stdout.strip() or None + + def list_audio_streams(): """Return [{id, app_name, media_name, node_name}, ...] for active output streams.""" import json as _json @@ -248,7 +307,51 @@ def get_cell(self, source_id, mix_id): ) def cells(self): - return dict(self._state) + """Per-cell state only; reserved scalar keys are not cells.""" + return {k: v for k, v in self._state.items() if "." in k} + + def resolve_output(self): + """The sink the Personal Mix should feed, or None if nothing is eligible. + + Explicit user choice first, then the Wave device's own headphone jack, + then the system default, then the highest-priority output. Each + candidate is checked against the live sink list, so a device that has + been unplugged, or a card profile that no longer exposes an output, + falls through instead of leaving the mix with no outlet. + + The default-sink step usually does not fire: the Personal Mix is + typically *itself* the default sink, and it is not eligible. The + priority fallback is what makes the automatic setting resolve to + something audible on a machine whose Wave device has no usable + headphone output. + """ + sinks = list_output_sinks() + eligible = {sink["name"] for sink in sinks} + + choice = self._state.get(OUTPUT_STATE_KEY, OUTPUT_AUTO) + if choice and choice != OUTPUT_AUTO and choice in eligible: + return choice + + if self.hp and self.hp in eligible: + return self.hp + + default = _default_sink_name() + if default and default in eligible: + return default + + if sinks: + return max(sinks, key=lambda sink: sink["priority"])["name"] + return None + + def get_output(self): + """The persisted output choice: a sink name, or OUTPUT_AUTO.""" + return self._state.get(OUTPUT_STATE_KEY, OUTPUT_AUTO) + + def set_output(self, name): + """Persist the output choice and respawn the Personal->output loopback.""" + self._state[OUTPUT_STATE_KEY] = name or OUTPUT_AUTO + self._save_state() + self._enqueue(("output",), self._do_retarget_output) def streams(self): """Snapshot of currently-known PipeWire output streams (id → info).""" @@ -407,14 +510,24 @@ def poll_streams(self): # ----- worker-side implementations ----- def _do_start(self): self._sweep_stale_loopbacks() - if self.hp: - self._spawn_loopback( - HP_LOOPBACK_KEY, PERSONAL_MIX_SINK, self.hp, HP_LOOPBACK_NODE, - ) + self._respawn_output_loopback() with self._lock: self._streams = {s["id"]: s for s in list_audio_streams()} self._reconcile_all() + def _respawn_output_loopback(self): + """(Re)create the Personal->output loopback for the current target.""" + self._destroy_loopback(HP_LOOPBACK_KEY) + target = self.resolve_output() + if target is None: + return + self._spawn_loopback( + HP_LOOPBACK_KEY, PERSONAL_MIX_SINK, target, HP_LOOPBACK_NODE, + ) + + def _do_retarget_output(self): + self._respawn_output_loopback() + def _do_remove_source(self, source_id): with self._lock: keys = [ From e9d6a26736eb8c6f1722f81f91a3065ee1ac0c97 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:29:36 -0500 Subject: [PATCH 04/99] Fix subprocess teardown: merge duplicate do_shutdown, reap dead loopbacks 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. --- Makefile | 2 +- wavexlr/app.py | 11 ++++------- wavexlr/mixer.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 40cc343..1057de4 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ SITEPKG := $(shell $(PYTHON) -c "import site; print(site.getsitepackages()[0])") install: install -dm755 $(DESTDIR)$(SITEPKG)/wavexlr - install -m644 wavexlr/__init__.py wavexlr/__main__.py wavexlr/app.py wavexlr/audio.py wavexlr/daemon.py wavexlr/device.py wavexlr/meter.py wavexlr/mixer.py wavexlr/mixmatrix.py wavexlr/paths.py wavexlr/probe.py wavexlr/profiles.py wavexlr/service.py wavexlr/setup.py wavexlr/sourcedialog.py wavexlr/sources.py wavexlr/style.css wavexlr/tray.py $(DESTDIR)$(SITEPKG)/wavexlr/ + install -m644 $(wildcard wavexlr/*.py) wavexlr/style.css $(DESTDIR)$(SITEPKG)/wavexlr/ install -dm755 $(BINDIR) printf '#!/bin/sh\nexec %s -m wavexlr "$$@"\n' "$(PYTHON)" > $(BINDIR)/openwave chmod 755 $(BINDIR)/openwave diff --git a/wavexlr/app.py b/wavexlr/app.py index 10f77b9..52099cf 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -768,12 +768,15 @@ def _load_css(self): ) def do_shutdown(self): - """Tear down loopback + meter subprocesses before the process exits.""" + """Stop polling, drop the USB link, and tear down loopback + meter + subprocesses before the process exits.""" if self._window is not None: + self._window._stop_polling() if hasattr(self._window, "meter"): self._window.meter.stop_all() if hasattr(self._window, "mixer"): self._window.mixer.stop() + self._window.dev.disconnect() Adw.Application.do_shutdown(self) def _on_close_request(self, window): @@ -864,12 +867,6 @@ def _on_replug_done(self, dialog, result, tmp_win): self._window = win win.present() - def do_shutdown(self): - if self._window: - self._window._stop_polling() - self._window.dev.disconnect() - Adw.Application.do_shutdown(self) - def main(): app = WaveXLRApp() diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 8b1aa03..6571d73 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -549,7 +549,26 @@ def _sweep_stale_loopbacks(): time.sleep(0.2) # give the kernel a beat to reap so we don't race # ----- internal ----- + def _reap_dead(self): + """Drop bookkeeping for loopbacks whose process has already exited. + + Nothing else reconciles self._procs against process reality, so an + out-of-band death — the child killed, or PipeWire restarted under it — + leaves a key that permanently blocks respawn, because _spawn_loopback + returns early on `key in self._procs`. The dead child also stays a + zombie, since only _destroy_loopback ever wait()s one. + """ + for key, proc in list(self._procs.items()): + if proc.poll() is None: + continue + try: + proc.wait(timeout=0) + except (subprocess.SubprocessError, OSError): + pass + self._procs.pop(key, None) + def _reconcile_all(self): + self._reap_dead() for source_id in (["mic"] + list(self._sources.keys())): for mix_id in MIX_SINKS: self._reconcile_cell(source_id, mix_id) From 3bbd28aa4b5c3dbe59e8085e79ff99021f8ddb59 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:33:53 -0500 Subject: [PATCH 05/99] Add a mix definition store, seeded with the three built-ins 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. --- wavexlr/mixes.py | 158 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 wavexlr/mixes.py diff --git a/wavexlr/mixes.py b/wavexlr/mixes.py new file mode 100644 index 0000000..09d51fd --- /dev/null +++ b/wavexlr/mixes.py @@ -0,0 +1,158 @@ +"""Mix definitions, persisted to ~/.config/openwave/mixdefs.json. + +Mix *identity* only — name, icon, and the PipeWire sink that carries it. +Per-cell levels live separately in ~/.config/openwave/mixes.json, written by +Mixer; keeping the two apart means a slider move can never clobber a +definition, and a definition edit can never zero a level. + +`sink` is stored explicitly rather than derived from `id` so that renaming a +mix never renames the PipeWire node an OBS or Discord capture is pointed at. +`description` is the node.description PipeWire publishes, which is distinct +from the name shown in our own UI for the same reason. +""" + +import copy +import json +import os +import re +import uuid + +CONFIG_PATH = os.path.expanduser("~/.config/openwave/mixdefs.json") + +# Ids are interpolated unquoted into pw-loopback properties and into +# "."-separated cell keys, so anything outside this set silently corrupts one +# or the other. uuid4().hex satisfies it; a display name must never be an id. +_ID_RE = re.compile(r"^[a-z0-9_]+$") + +DEFAULT_ICON = "audio-speakers-symbolic" + + +class Unreadable(Exception): + """mixdefs.json exists but could not be parsed.""" + + +# Insertion order is column order — sources.py already relies on dict order +# for row order, and json round-trips it. Do not add an "order" field. +DEFAULT_MIXES = { + "personal": { + "id": "personal", + "name": "Personal Mix", + "subtitle": "What you hear", + "description": "OpenWave Personal Mix", + "sink": "openwave_personal_mix", + "icon_name": "audio-headphones-symbolic", + }, + "chat": { + "id": "chat", + "name": "Chat Mix", + "subtitle": "To voice apps (v0.3.0)", + "description": "OpenWave Chat Mix", + "sink": "openwave_chat_mix", + "icon_name": "system-users-symbolic", + }, + "record": { + "id": "record", + "name": "Record Mix", + "subtitle": "To OBS / recording (v0.3.0)", + "description": "OpenWave Record Mix", + "sink": "openwave_record_mix", + "icon_name": "media-record-symbolic", + }, +} + + +def _atomic_write(path, payload): + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(payload, f, indent=2) + os.replace(tmp, path) + + +def load(): + """Return the stored mixes, or None if the file does not exist. + + Raises Unreadable when the file is present but corrupt. Callers must not + conflate that with "the user has no mixes": the consumer overwrites the + generated PipeWire config, so treating a parse failure as an empty store + would delete every sink. + """ + if not os.path.exists(CONFIG_PATH): + return None + try: + with open(CONFIG_PATH) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + raise Unreadable(str(exc)) from exc + if not isinstance(data, dict): + raise Unreadable("top-level value is not an object") + return data + + +def load_seeded(): + """Load the store, creating it from DEFAULT_MIXES on first run. + + A corrupt file is preserved as mixdefs.json.corrupt and replaced with the + defaults, so a bad write costs the user their customisation but never + leaves the app with no mixes at all. + """ + try: + data = load() + except Unreadable: + try: + os.replace(CONFIG_PATH, CONFIG_PATH + ".corrupt") + except OSError: + pass + data = None + if data is None: + data = copy.deepcopy(DEFAULT_MIXES) + save(data) + return data + + +def save(mixes): + _atomic_write(CONFIG_PATH, mixes) + + +def new_mix(*, name, subtitle="", icon_name=DEFAULT_ICON): + """Return a fresh mix dict ready to insert into the mixes mapping.""" + mix_id = uuid.uuid4().hex[:12] + if not _ID_RE.match(mix_id): # defensive; uuid4().hex always matches + raise ValueError(f"generated id is not safe to interpolate: {mix_id!r}") + return { + "id": mix_id, + "name": name, + "subtitle": subtitle, + "description": f"OpenWave {name}", + "sink": f"openwave_mix_{mix_id}", + "icon_name": icon_name, + } + + +def add(mixes, mix): + mixes[mix["id"]] = mix + save(mixes) + return mixes + + +def remove(mixes, mix_id): + mixes.pop(mix_id, None) + save(mixes) + return mixes + + +def update(mixes, mix_id, **fields): + """Edit a mix in place, preserving its id and sink. + + id and sink are structural: cell keys in mixes.json are ".", + and other applications target the sink by name. + """ + mix = mixes.get(mix_id) + if mix is None: + return mixes + for key, value in fields.items(): + if key in ("id", "sink"): + continue + mix[key] = value + save(mixes) + return mixes From ee12e58b5223a86c08e91502c0dcc6dabc44dff4 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:36:21 -0500 Subject: [PATCH 06/99] Drive mixer, setup and app from the mix store 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. --- wavexlr/app.py | 33 ++++++++---------- wavexlr/mixer.py | 47 ++++++++++++++++++++------ wavexlr/setup.py | 87 ++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 119 insertions(+), 48 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 52099cf..46285ab 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -15,7 +15,7 @@ from .mixer import Mixer, list_output_sinks, OUTPUT_AUTO from .mixmatrix import MixMatrix from .sourcedialog import AddSourceDialog -from . import paths, setup, service, sources as sources_module +from . import paths, setup, service, sources as sources_module, mixes as mixes_module logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") @@ -39,10 +39,12 @@ def __init__(self, **kwargs): # during a drag into one set_cell. {(source_id, mix_id): timeout_id}. self._cell_debounce_ids = {} self._sources = sources_module.load() + self._mixes = mixes_module.load_seeded() self._build_ui() self._update_service_status() self.mixer = Mixer() + self.mixer.set_mixes(self._mixes) self.mixer.set_sources(self._sources) self.mixer.start() self._refresh_outputs() @@ -100,21 +102,13 @@ def _build_ui(self): self.matrix = MixMatrix() self.split.set_content(self.matrix) - self.matrix.add_mix( - "personal", title="Personal Mix", - subtitle="What you hear", - icon_name="audio-headphones-symbolic", - ) - self.matrix.add_mix( - "chat", title="Chat Mix", - subtitle="To voice apps (v0.3.0)", - icon_name="system-users-symbolic", - ) - self.matrix.add_mix( - "record", title="Record Mix", - subtitle="To OBS / recording (v0.3.0)", - icon_name="media-record-symbolic", - ) + for mix_id, mix in self._mixes.items(): + self.matrix.add_mix( + mix_id, + title=mix.get("name", mix_id), + subtitle=mix.get("subtitle", ""), + icon_name=mix.get("icon_name", mixes_module.DEFAULT_ICON), + ) self.mic_source = self.matrix.add_source( "mic", name="Microphone", @@ -573,7 +567,7 @@ def _wire_matrix_cells(self): """Bind each per-cell slider/mute to the mixer + restore persisted levels.""" source_ids = ["mic"] + list(self._sources.keys()) for source_id in source_ids: - for mix_id in ("personal", "chat", "record"): + for mix_id in self._mixes: self._wire_cell(source_id, mix_id) def _wire_cell(self, source_id, mix_id): @@ -656,9 +650,8 @@ def _on_source_confirmed(self, _dialog, name, match_app_name, icon_name): has_level=True, removable=True, ) - self._wire_cell(source["id"], "personal") - self._wire_cell(source["id"], "chat") - self._wire_cell(source["id"], "record") + for mix_id in self._mixes: + self._wire_cell(source["id"], mix_id) self.mixer.set_sources(self._sources) self.mixer.poll_streams() self._refresh_app_meter(source["id"]) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 6571d73..cc831c9 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -42,11 +42,6 @@ def _set_pdeathsig(): CONFIG_PATH = os.path.expanduser("~/.config/openwave/mixes.json") -MIX_SINKS = { - "personal": "openwave_personal_mix", - "chat": "openwave_chat_mix", - "record": "openwave_record_mix", -} PERSONAL_MIX_SINK = "openwave_personal_mix" HP_LOOPBACK_KEY = "_personal_to_hp" HP_LOOPBACK_NODE = "openwave_loop_personal_to_hp" @@ -242,7 +237,12 @@ def __init__(self): self._procs = {} self._state = self._load_state() self._sources = {} + self._mixes = {} self._streams = {} + # _do_start ends with a full reconcile. Reconciling before it would + # route cells into sinks it has not yet created or swept, so + # set_sources/set_mixes stay silent until it has run once. + self._started = False self.mic, self.hp = find_wave_xlr_alsa() # Background worker: every operation that talks to pw-loopback / @@ -479,7 +479,26 @@ def set_sources(self, sources): """Update the app-source configuration; reconcile on worker.""" with self._lock: self._sources = dict(sources) - self._enqueue(("set_sources",), self._reconcile_all) + self._push_reconcile() + + def set_mixes(self, mixes): + """Update the mix configuration; reconcile on worker.""" + with self._lock: + self._mixes = dict(mixes) + self._push_reconcile() + + def _push_reconcile(self): + """Queue one reconcile pass, coalescing with any already pending. + + set_sources and set_mixes share a key so configuring both at startup + costs one pass, not two. + """ + if self._started: + self._enqueue(("reconcile",), self._reconcile_all) + + def _mix_sink(self, mix_id): + """The PipeWire sink carrying a mix, or None if it is not defined.""" + return (self._mixes.get(mix_id) or {}).get("sink") def remove_source(self, source_id): """Forget persisted cells now; tear down loopbacks on worker.""" @@ -513,6 +532,7 @@ def _do_start(self): self._respawn_output_loopback() with self._lock: self._streams = {s["id"]: s for s in list_audio_streams()} + self._started = True self._reconcile_all() def _respawn_output_loopback(self): @@ -569,8 +589,15 @@ def _reap_dead(self): def _reconcile_all(self): self._reap_dead() - for source_id in (["mic"] + list(self._sources.keys())): - for mix_id in MIX_SINKS: + # Snapshot both axes under the lock: set_sources/set_mixes replace + # these dicts from the GTK thread, and a mutation mid-iteration would + # raise into _worker_loop's bare except, silently leaving a mix + # unwired. + with self._lock: + source_ids = ["mic"] + list(self._sources) + mix_ids = list(self._mixes) + for source_id in source_ids: + for mix_id in mix_ids: self._reconcile_cell(source_id, mix_id) def _reconcile_cell(self, source_id, mix_id): @@ -585,7 +612,7 @@ def _reconcile_cell(self, source_id, mix_id): def _reconcile_mic_cell(self, mix_id, volume, muted): if not self.mic: return - mix_sink = MIX_SINKS.get(mix_id) + mix_sink = self._mix_sink(mix_id) if not mix_sink: return key = ("mic", mix_id) @@ -604,7 +631,7 @@ def _reconcile_app_cell(self, source_id, mix_id, volume, muted): source = self._sources.get(source_id) if not source: return - mix_sink = MIX_SINKS.get(mix_id) + mix_sink = self._mix_sink(mix_id) if not mix_sink: return match = source.get("match_app_name") diff --git a/wavexlr/setup.py b/wavexlr/setup.py index 694486a..663aa1f 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -1,6 +1,7 @@ """First-run setup: udev rule, WirePlumber rule, audio service.""" import os +import shutil import subprocess from . import paths, service @@ -140,11 +141,36 @@ def install_wireplumber(): return True -MIX_SINKS = ( - ("openwave_personal_mix", "OpenWave Personal Mix"), - ("openwave_chat_mix", "OpenWave Chat Mix"), - ("openwave_record_mix", "OpenWave Record Mix"), -) +GENERATED_MARKER = "# GENERATED by OpenWave" + +MIXES_HEADER = GENERATED_MARKER + """ from ~/.config/openwave/mixdefs.json. +# +# Hand edits are overwritten whenever a mix is added, renamed or removed. +# Edit mixes in the app instead. +# +# Each mix is a null sink: applications play into it, and OpenWave carries its +# monitor to an output device (or not, for a mix that is only captured). +""" + + +def render_mixes_conf(mixes): + """Render the PipeWire config defining every mix sink.""" + entries = [] + for mix in mixes.values(): + entries.append( + " { factory = adapter\n" + " args = {\n" + " factory.name = support.null-audio-sink\n" + f" node.name = {mix['sink']}\n" + f' node.description = "{mix["description"]}"\n' + " media.class = Audio/Sink\n" + " audio.position = [ FL FR ]\n" + " object.linger = true\n" + " monitor.channel-volumes = true\n" + " }\n" + " }\n" + ) + return MIXES_HEADER + "\ncontext.objects = [\n" + "".join(entries) + "]\n" def _mix_sink_exists(name): @@ -184,21 +210,46 @@ def _create_mix_sink_live(name, description): pass -def install_mixes(): - """Drop the three virtual mix sinks into the user's PipeWire config.""" - src = mixes_source() - if src is None: - raise FileNotFoundError( - f"Mix sinks config source not found: share/openwave/pipewire/" - f"{MIXES_NAME} is missing from this install" - ) - with open(src) as f: - content = f.read() +def install_mixes(mixes=None): + """Write the generated mix config and materialise the sinks. + + Refuses to write an empty config: the caller's store may have failed to + load, and an empty render would silently delete every mix sink. + """ + if mixes is None: + from . import mixes as mixes_module + mixes = mixes_module.load_seeded() + if not mixes: + return False + + content = render_mixes_conf(mixes) os.makedirs(os.path.dirname(MIXES_PATH), exist_ok=True) - with open(MIXES_PATH, "w") as f: + + # Preserve a hand-written config once, before the first generated write + # replaces it. The same directory already uses the .bak convention. + if os.path.exists(MIXES_PATH): + try: + with open(MIXES_PATH) as f: + existing = f.read() + except OSError: + existing = GENERATED_MARKER + if GENERATED_MARKER not in existing and not os.path.exists(MIXES_PATH + ".bak"): + try: + shutil.copy2(MIXES_PATH, MIXES_PATH + ".bak") + except OSError: + pass + if existing == content: + for mix in mixes.values(): + _create_mix_sink_live(mix["sink"], mix["description"]) + return True + + tmp = MIXES_PATH + ".tmp" + with open(tmp, "w") as f: f.write(content) - for name, desc in MIX_SINKS: - _create_mix_sink_live(name, desc) + os.replace(tmp, MIXES_PATH) + + for mix in mixes.values(): + _create_mix_sink_live(mix["sink"], mix["description"]) return True From 8cf68c528161e498d751662fdc11ed219f54681a Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:44:33 -0500 Subject: [PATCH 07/99] Give every mix its own output device 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_ 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. --- wavexlr/app.py | 36 ++++++++---- wavexlr/mixer.py | 150 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 136 insertions(+), 50 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 46285ab..4d56862 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -12,7 +12,7 @@ from .device import WaveDevice from .meter import MeterMonitor -from .mixer import Mixer, list_output_sinks, OUTPUT_AUTO +from .mixer import Mixer, list_output_sinks, OUTPUT_AUTO, OUTPUT_NONE from .mixmatrix import MixMatrix from .sourcedialog import AddSourceDialog from . import paths, setup, service, sources as sources_module, mixes as mixes_module @@ -479,11 +479,20 @@ def _send_hp(self, db): self._usb_async(lambda: self.dev.set_hp_volume_db(db), on_error=self._on_usb_error) return False + def _primary_mix_id(self): + """The mix the device-pane picker controls.""" + if "personal" in self._mixes: + return "personal" + return next(iter(self._mixes), None) + def _refresh_outputs(self): """Rebuild the output picker from the live sink list.""" + mix_id = self._primary_mix_id() + if mix_id is None: + return sinks = list_output_sinks() - current = self.mixer.get_output() - resolved = self.mixer.resolve_output() + current = self.mixer.get_output(mix_id) + resolved = self.mixer.resolve_output(mix_id, sinks=sinks) auto_label = "Automatic" if resolved: @@ -496,16 +505,20 @@ def _refresh_outputs(self): self._updating_outputs = True try: self.output_model.splice(0, self.output_model.get_n_items(), None) - self._output_names = [OUTPUT_AUTO] + # Automatic stays at index 0: the not-found fallback below selects + # index 0, and that must describe what the audio is actually doing. + self._output_names = [OUTPUT_AUTO, OUTPUT_NONE] self.output_model.append(auto_label) + self.output_model.append("Not monitored") for sink in sinks: self.output_model.append(sink["description"]) self._output_names.append(sink["name"]) - try: - index = self._output_names.index(current) - except ValueError: - index = 0 - self.output_row.set_selected(index) + if current not in self._output_names: + # A remembered device that is currently absent: show it rather + # than silently substituting a sentinel. + self.output_model.append(f"{current} (unavailable)") + self._output_names.append(current) + self.output_row.set_selected(self._output_names.index(current)) finally: self._updating_outputs = False @@ -515,7 +528,10 @@ def _on_output_changed(self, row, _param): index = row.get_selected() if not 0 <= index < len(self._output_names): return - self.mixer.set_output(self._output_names[index]) + mix_id = self._primary_mix_id() + if mix_id is None: + return + self.mixer.set_output(mix_id, self._output_names[index]) # Re-label "Automatic" once the mixer has resolved the new target. GLib.timeout_add(400, self._refresh_outputs_once) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index cc831c9..91f8827 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -42,14 +42,20 @@ def _set_pdeathsig(): CONFIG_PATH = os.path.expanduser("~/.config/openwave/mixes.json") -PERSONAL_MIX_SINK = "openwave_personal_mix" -HP_LOOPBACK_KEY = "_personal_to_hp" -HP_LOOPBACK_NODE = "openwave_loop_personal_to_hp" # Reserved key in mixes.json holding the Personal Mix's output device. Cell # keys are always ".", so a bare word cannot collide with one. -OUTPUT_STATE_KEY = "output" +# Per-mix output devices live under a nested reserved key. Cell keys are +# always ".", so a dot-free word cannot collide with one. +OUTPUTS_STATE_KEY = "outputs" +# Superseded scalar holding the Personal Mix's output. Still written for one +# release so an older build reading this file keeps working. +LEGACY_OUTPUT_KEY = "output" OUTPUT_AUTO = "auto" +OUTPUT_NONE = "none" +# The mix seeded as "what you hear" monitors by default; anything else stays +# silent until asked, which is correct for a mix that only gets captured. +_MONITORING_MIX_ID = "personal" def _pactl_short(kind): @@ -236,6 +242,8 @@ def __init__(self): self._lock = Lock() self._procs = {} self._state = self._load_state() + if self._migrate_state(): + self._save_state() self._sources = {} self._mixes = {} self._streams = {} @@ -288,11 +296,36 @@ def _worker_loop(self): # ----- persistence ----- def _load_state(self): + """Read persisted state. Pure: never writes, since it is what + produces self._state and writing from here would race its own caller.""" try: with open(CONFIG_PATH) as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): + data = json.load(f) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(data, dict): return {} + return data + + def _migrate_state(self): + """Fold the legacy scalar output key into the per-mix mapping. + + Returns True if anything changed. Called once from __init__ after + _load_state, never from inside it. + """ + outputs = self._state.get(OUTPUTS_STATE_KEY) + if not isinstance(outputs, dict): + outputs = {} + legacy = self._state.get(LEGACY_OUTPUT_KEY) + changed = False + if isinstance(legacy, str) and _MONITORING_MIX_ID not in outputs: + # Only when unset: a per-mix choice is newer than the scalar. + outputs[_MONITORING_MIX_ID] = legacy + changed = True + if changed or OUTPUTS_STATE_KEY not in self._state: + self._state[OUTPUTS_STATE_KEY] = outputs + changed = True + return changed def _save_state(self): os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) @@ -310,48 +343,70 @@ def cells(self): """Per-cell state only; reserved scalar keys are not cells.""" return {k: v for k, v in self._state.items() if "." in k} - def resolve_output(self): - """The sink the Personal Mix should feed, or None if nothing is eligible. + def _default_output_for(self, mix_id): + return OUTPUT_AUTO if mix_id == _MONITORING_MIX_ID else OUTPUT_NONE + + def get_output(self, mix_id): + """The persisted choice for a mix: a sink name, OUTPUT_AUTO or OUTPUT_NONE.""" + outputs = self._state.get(OUTPUTS_STATE_KEY) or {} + return outputs.get(mix_id, self._default_output_for(mix_id)) - Explicit user choice first, then the Wave device's own headphone jack, - then the system default, then the highest-priority output. Each - candidate is checked against the live sink list, so a device that has - been unplugged, or a card profile that no longer exposes an output, - falls through instead of leaving the mix with no outlet. + def resolve_output(self, mix_id, sinks=None, default_sink=None): + """The sink a mix should feed, or None if it should not be monitored. - The default-sink step usually does not fire: the Personal Mix is - typically *itself* the default sink, and it is not eligible. The - priority fallback is what makes the automatic setting resolve to - something audible on a machine whose Wave device has no usable - headphone output. + Explicit choice first, then the Wave device's own headphone jack, then + the system default, then the highest-priority output. Each candidate is + checked against the live sink list, so an unplugged device or a card + profile that no longer exposes an output falls through instead of + leaving the mix with no outlet. + + The default-sink step rarely fires: the monitoring mix is typically + itself the default sink, and mix sinks are not eligible. The priority + fallback is what makes OUTPUT_AUTO resolve to something audible on a + machine whose Wave device has no usable headphone output. + + `sinks` and `default_sink` may be passed in by a caller resolving + several mixes at once, so the subprocess cost is paid once rather than + per mix. """ - sinks = list_output_sinks() + choice = self.get_output(mix_id) + if choice == OUTPUT_NONE: + return None + + if sinks is None: + sinks = list_output_sinks() eligible = {sink["name"] for sink in sinks} - choice = self._state.get(OUTPUT_STATE_KEY, OUTPUT_AUTO) if choice and choice != OUTPUT_AUTO and choice in eligible: return choice if self.hp and self.hp in eligible: return self.hp - default = _default_sink_name() - if default and default in eligible: - return default + if default_sink is None: + default_sink = _default_sink_name() + if default_sink and default_sink in eligible: + return default_sink if sinks: return max(sinks, key=lambda sink: sink["priority"])["name"] return None - def get_output(self): - """The persisted output choice: a sink name, or OUTPUT_AUTO.""" - return self._state.get(OUTPUT_STATE_KEY, OUTPUT_AUTO) - - def set_output(self, name): - """Persist the output choice and respawn the Personal->output loopback.""" - self._state[OUTPUT_STATE_KEY] = name or OUTPUT_AUTO - self._save_state() - self._enqueue(("output",), self._do_retarget_output) + def set_output(self, mix_id, name): + """Persist a mix's output choice and respawn its loopback.""" + with self._lock: + outputs = self._state.get(OUTPUTS_STATE_KEY) + if not isinstance(outputs, dict): + outputs = {} + self._state[OUTPUTS_STATE_KEY] = outputs + outputs[mix_id] = name or OUTPUT_AUTO + if mix_id == _MONITORING_MIX_ID: + # Keep the superseded scalar in step for one release. + self._state[LEGACY_OUTPUT_KEY] = outputs[mix_id] + self._save_state() + self._enqueue( + ("output", mix_id), lambda mid=mix_id: self._do_retarget_output(mid), + ) def streams(self): """Snapshot of currently-known PipeWire output streams (id → info).""" @@ -529,24 +584,39 @@ def poll_streams(self): # ----- worker-side implementations ----- def _do_start(self): self._sweep_stale_loopbacks() - self._respawn_output_loopback() + self._respawn_all_output_loopbacks() with self._lock: self._streams = {s["id"]: s for s in list_audio_streams()} self._started = True self._reconcile_all() - def _respawn_output_loopback(self): - """(Re)create the Personal->output loopback for the current target.""" - self._destroy_loopback(HP_LOOPBACK_KEY) - target = self.resolve_output() + def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): + """(Re)create one mix's output loopback for its current target.""" + key = ("output", mix_id) + self._destroy_loopback(key) + mix_sink = self._mix_sink(mix_id) + if not mix_sink: + return + target = self.resolve_output(mix_id, sinks=sinks, default_sink=default_sink) if target is None: return self._spawn_loopback( - HP_LOOPBACK_KEY, PERSONAL_MIX_SINK, target, HP_LOOPBACK_NODE, + key, mix_sink, target, f"openwave_loop_out_{mix_id}", ) - def _do_retarget_output(self): - self._respawn_output_loopback() + def _respawn_all_output_loopbacks(self): + """Retarget every mix, paying the sink-enumeration cost once.""" + sinks = list_output_sinks() + default_sink = _default_sink_name() + with self._lock: + mix_ids = list(self._mixes) + for mix_id in mix_ids: + self._respawn_output_loopback( + mix_id, sinks=sinks, default_sink=default_sink, + ) + + def _do_retarget_output(self, mix_id): + self._respawn_output_loopback(mix_id) def _do_remove_source(self, source_id): with self._lock: From c69a9a9bfc34c451c5a24ca53c68f7cbd0cc4f36 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 15:46:53 -0500 Subject: [PATCH 08/99] Show Wave XLR gain in dB instead of a raw hex word 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. --- wavexlr/app.py | 2 +- wavexlr/profiles.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 4d56862..a1efbf8 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -180,7 +180,7 @@ def _build_device_pane(self, parent): mic_group.add(mute_row) gain_row = Adw.ActionRow(title="Gain") - self.gain_label = Gtk.Label(label="0x0000", width_chars=8, xalign=1) + self.gain_label = Gtk.Label(label="—", width_chars=8, xalign=1) self.gain_label.add_css_class("monospace") gain_row.add_suffix(self.gain_label) mic_group.add(gain_row) diff --git a/wavexlr/profiles.py b/wavexlr/profiles.py index 499d013..5ed92a0 100644 --- a/wavexlr/profiles.py +++ b/wavexlr/profiles.py @@ -69,7 +69,11 @@ def has_monitor_mix(self): devinfo_serial=(27, 47), off_gain=0, gain_max=0x5000, - gain_scale=None, + # 256 raw units per dB, so gain_max is 80 dB. Measured against the ALSA + # 'Mic Capture Volume' control on a Wave XLR MK.2 at four points across + # the range (20/40/60/75 dB): 0x1400/0x2800/0x3C00/0x4B00, exactly 256.00 + # raw per dB at every point. + gain_scale=256, off_mute=4, off_hp_vol=9, hp_fmt=' Date: Sat, 29 Aug 2026 16:26:16 -0500 Subject: [PATCH 09/99] Read ALSA control ranges from the driver instead of hardcoding them _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. --- wavexlr/device.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/wavexlr/device.py b/wavexlr/device.py index ea7b92f..b6abcb7 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -11,6 +11,7 @@ import ctypes import ctypes.util +import re import struct import subprocess import threading @@ -85,24 +86,44 @@ def _alsa_get(card): return state +# Control ranges differ per device and per kernel driver, so they are read +# from the driver rather than assumed. Cached: they cannot change for a card. +_ALSA_CTL_MAX = {} + + +def _alsa_ctl_max(card, numid, fallback): + """The highest value a control accepts, per the driver.""" + key = (card, numid) + if key not in _ALSA_CTL_MAX: + match = re.search(r",max=(-?\d+)", _amixer(card, "cget", f"numid={numid}")) + _ALSA_CTL_MAX[key] = int(match.group(1)) if match else fallback + return _ALSA_CTL_MAX[key] + + def _alsa_set_mute(card, muted): _amixer(card, "cset", "numid=5", "off" if muted else "on") def _alsa_set_hp_vol(card, value): - """Set ALSA HP volume (numid=4, 0-120).""" - _amixer(card, "cset", "numid=4", str(max(0, min(120, value)))) + """Set ALSA HP volume (numid=4), clamped to the control's real range.""" + top = _alsa_ctl_max(card, 4, 120) + _amixer(card, "cset", "numid=4", str(max(0, min(top, value)))) def _alsa_set_gain(card, value): - """Set ALSA mic gain (numid=6, 0-80).""" - _amixer(card, "cset", "numid=6", str(max(0, min(80, value)))) + """Set ALSA mic gain (numid=6), clamped to the control's real range.""" + top = _alsa_ctl_max(card, 6, 150) + _amixer(card, "cset", "numid=6", str(max(0, min(top, value)))) def _fw_gain_to_alsa(fw_gain_raw, scale): - """Map firmware gain (raw / scale dB) to ALSA (0-80, 0.5 dB steps).""" - db = fw_gain_raw / scale - return max(0, min(80, round(db / 0.5))) + """Map firmware gain (raw / scale dB) to ALSA steps of 0.5 dB. + + The upper clamp belongs to the setter, which knows the control's real + range. Clamping here to a constant silently halved any gain above 40 dB + on a device whose control goes higher. + """ + return max(0, round((fw_gain_raw / scale) / 0.5)) def _fw_hp_to_alsa(fw_hp_raw, scale): From 0515ec47615b64ed1acceb7d693215cc3ad7cbcf Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:37:07 -0500 Subject: [PATCH 10/99] Create, rename and delete mixes from the mix column header 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 - ' label, the ' (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. --- wavexlr/app.py | 245 ++++++++++++++++++++++--------- wavexlr/mixdialog.py | 166 ++++++++++++++++++++++ wavexlr/mixer.py | 70 ++++++++- wavexlr/mixmatrix.py | 332 ++++++++++++++++++++++++++++++++++++++++--- wavexlr/setup.py | 84 ++++++++++- 5 files changed, 805 insertions(+), 92 deletions(-) create mode 100644 wavexlr/mixdialog.py diff --git a/wavexlr/app.py b/wavexlr/app.py index a1efbf8..122f141 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -12,7 +12,10 @@ from .device import WaveDevice from .meter import MeterMonitor -from .mixer import Mixer, list_output_sinks, OUTPUT_AUTO, OUTPUT_NONE +from .mixer import ( + Mixer, list_output_sinks, default_sink_name, OUTPUT_AUTO, OUTPUT_NONE, +) +from .mixdialog import MixDialog from .mixmatrix import MixMatrix from .sourcedialog import AddSourceDialog from . import paths, setup, service, sources as sources_module, mixes as mixes_module @@ -38,6 +41,8 @@ def __init__(self, **kwargs): # Debounce slider events to coalesce a flurry of value-changed signals # during a drag into one set_cell. {(source_id, mix_id): timeout_id}. self._cell_debounce_ids = {} + # One-shot re-read of the routing after a mix output change settles. + self._output_refresh_id = None self._sources = sources_module.load() self._mixes = mixes_module.load_seeded() @@ -130,6 +135,10 @@ def _build_ui(self): self.matrix.connect("add-source-clicked", self._on_add_source_clicked) self.matrix.connect("remove-source-clicked", self._on_remove_source_clicked) + self.matrix.connect("add-mix-clicked", self._on_add_mix_clicked) + self.matrix.connect("rename-mix-clicked", self._on_rename_mix_clicked) + self.matrix.connect("remove-mix-clicked", self._on_remove_mix_clicked) + self.matrix.connect("mix-output-changed", self._on_mix_output_changed) # --- Sidebar: device controls ----------------------------------------- sidebar_scroll = Gtk.ScrolledWindow( @@ -249,21 +258,8 @@ def _build_device_pane(self, parent): self.mix_scale.connect("value-changed", self._on_mix_changed) parent.append(self.mix_scale) - # --- Personal Mix output --- - out_group = Adw.PreferencesGroup( - title="Personal Mix Output", - description="Where the Personal Mix is played back", - ) - parent.append(out_group) - - self._updating_outputs = False - self._output_names = [] - self.output_row = Adw.ComboRow(title="Device") - self.output_model = Gtk.StringList() - self.output_row.set_model(self.output_model) - self.output_row.connect("notify::selected", self._on_output_changed) - out_group.add(self.output_row) - # Populated in __init__ once the Mixer exists; _build_ui() runs first. + # Output routing is per mix and lives in each mix column's header + # menu, not here — one device combo could only ever speak for one mix. # --- Device info --- info_group = Adw.PreferencesGroup(title="Device Info") @@ -479,65 +475,180 @@ def _send_hp(self, db): self._usb_async(lambda: self.dev.set_hp_volume_db(db), on_error=self._on_usb_error) return False - def _primary_mix_id(self): - """The mix the device-pane picker controls.""" - if "personal" in self._mixes: - return "personal" - return next(iter(self._mixes), None) - - def _refresh_outputs(self): - """Rebuild the output picker from the live sink list.""" - mix_id = self._primary_mix_id() - if mix_id is None: - return - sinks = list_output_sinks() + # ----- per-mix output routing (shown in each column header's menu) ----- + def _output_entries(self, mix_id, sinks, default_sink): + """(entries, current, summary, monitored) for one mix's header menu.""" current = self.mixer.get_output(mix_id) - resolved = self.mixer.resolve_output(mix_id, sinks=sinks) + resolved = self.mixer.resolve_output( + mix_id, sinks=sinks, default_sink=default_sink, + ) + descriptions = {sink["name"]: sink["description"] for sink in sinks} auto_label = "Automatic" - if resolved: - desc = next( - (s["description"] for s in sinks if s["name"] == resolved), None, + if current == OUTPUT_AUTO and resolved in descriptions: + # Only name the device when Automatic is what is actually in force: + # with an explicit sink chosen, resolve_output returns that sink, + # and labelling Automatic with it would claim a resolution that is + # not the one Automatic would pick. + auto_label = f"Automatic — {descriptions[resolved]}" + + # Automatic stays first: it is the entry that describes the default + # behaviour, and a mix with no stored choice lands on it. + entries = [(OUTPUT_AUTO, auto_label), (OUTPUT_NONE, "Not monitored")] + entries += [(sink["name"], sink["description"]) for sink in sinks] + if current not in [name for name, _ in entries]: + # A remembered device that is currently absent: show it rather than + # silently substituting a sentinel. + entries.append((current, f"{current} (unavailable)")) + + if current == OUTPUT_NONE: + summary, monitored = "Not monitored", False + elif resolved is None: + summary, monitored = "No output", False + else: + summary, monitored = descriptions.get(resolved, resolved), True + return entries, current, summary, monitored + + def _refresh_outputs(self): + """Push the live sink list into every mix header's output menu.""" + sinks = list_output_sinks() + default_sink = default_sink_name() + for mix_id in self._mixes: + entries, current, summary, monitored = self._output_entries( + mix_id, sinks, default_sink, ) - if desc: - auto_label = f"Automatic \u2014 {desc}" - - self._updating_outputs = True - try: - self.output_model.splice(0, self.output_model.get_n_items(), None) - # Automatic stays at index 0: the not-found fallback below selects - # index 0, and that must describe what the audio is actually doing. - self._output_names = [OUTPUT_AUTO, OUTPUT_NONE] - self.output_model.append(auto_label) - self.output_model.append("Not monitored") - for sink in sinks: - self.output_model.append(sink["description"]) - self._output_names.append(sink["name"]) - if current not in self._output_names: - # A remembered device that is currently absent: show it rather - # than silently substituting a sentinel. - self.output_model.append(f"{current} (unavailable)") - self._output_names.append(current) - self.output_row.set_selected(self._output_names.index(current)) - finally: - self._updating_outputs = False - - def _on_output_changed(self, row, _param): - if self._updating_outputs: + self.matrix.set_mix_outputs( + mix_id, entries, current, summary, monitored, + ) + + def _on_mix_output_changed(self, _matrix, mix_id, name): + self.mixer.set_output(mix_id, name) + # Re-label "Automatic — " once the mixer has retargeted the + # loopback. A burst of changes collapses into one refresh. + if self._output_refresh_id is not None: + GLib.source_remove(self._output_refresh_id) + self._output_refresh_id = GLib.timeout_add(400, self._refresh_outputs_tick) + + def _refresh_outputs_tick(self): + self._output_refresh_id = None + self._refresh_outputs() + return GLib.SOURCE_REMOVE + + # ----- mix create / rename / delete ----- + def _on_add_mix_clicked(self, _matrix): + dialog = MixDialog( + heading="Add Mix", confirm_label="Add Mix", + name="", icon_name=mixes_module.DEFAULT_ICON, + ) + dialog.connect("mix-confirmed", self._on_mix_created) + dialog.present(self) + + def _on_mix_created(self, _dialog, name, icon_name): + mix = mixes_module.new_mix(name=name, icon_name=icon_name) + self._mixes = mixes_module.add(self._mixes, mix) + self.matrix.add_mix( + mix["id"], + title=mix["name"], + subtitle=mix.get("subtitle", ""), + icon_name=mix["icon_name"], + ) + for source_id in ["mic"] + list(self._sources): + self._wire_cell(source_id, mix["id"]) + # install_mixes shells out to pw-cli/pactl for seconds at a time, so it + # runs off the main thread; the mixer is told about the mix only once + # the sink it would route into actually exists. + self._usb_async( + lambda defs=dict(self._mixes): setup.install_mixes(defs), + on_done=self._on_mix_installed, + on_error=self._on_mix_install_failed, + ) + + def _on_mix_installed(self, _ok): + self.mixer.set_mixes(self._mixes) + self._refresh_outputs() + + def _on_mix_install_failed(self, exc): + """Register the mix anyway, and say that its sink is missing. + + The mixer must learn about the mix whether or not the sink was + created: without this the column is drawn and persisted while every + cell in it stays silently inert for the rest of the session, with + nothing shown to explain why. _mix_sink() still resolves, so the cells + reconcile as soon as the sink appears. + """ + logging.error("Failed to install mix sinks: %s", exc) + self.mixer.set_mixes(self._mixes) + self._refresh_outputs() + + def _on_rename_mix_clicked(self, _matrix, mix_id): + mix = self._mixes.get(mix_id) + if mix is None: return - index = row.get_selected() - if not 0 <= index < len(self._output_names): + dialog = MixDialog( + heading="Rename Mix", confirm_label="Save", + name=mix.get("name", ""), + icon_name=mix.get("icon_name", mixes_module.DEFAULT_ICON), + ) + dialog.connect("mix-confirmed", self._on_mix_renamed, mix_id) + dialog.present(self) + + def _on_mix_renamed(self, _dialog, name, icon_name, mix_id): + if mix_id not in self._mixes: return - mix_id = self._primary_mix_id() - if mix_id is None: + # Name and icon only. mixes.update already refuses id and sink, and + # leaving `description` alone keeps the node.description PipeWire + # publishes in step with the sink OBS or Discord is already bound to — + # which is the whole reason a rename is safe. + self._mixes = mixes_module.update( + self._mixes, mix_id, name=name, icon_name=icon_name, + ) + self.matrix.set_mix(mix_id, title=name, icon_name=icon_name) + self.mixer.set_mixes(self._mixes) + # Renders byte-identical config (sink and description are untouched), + # so this only re-asserts that the sink is live. Still off the main + # thread, because proving that costs a pactl round trip. + self._usb_async(lambda defs=dict(self._mixes): setup.install_mixes(defs)) + + def _on_remove_mix_clicked(self, _matrix, mix_id): + if len(self._mixes) <= 1: + return # the header control is already insensitive; belt and braces + mix = self._mixes.get(mix_id) + if mix is None: return - self.mixer.set_output(mix_id, self._output_names[index]) - # Re-label "Automatic" once the mixer has resolved the new target. - GLib.timeout_add(400, self._refresh_outputs_once) + name = mix.get("name", "this mix") + description = mix.get("description") or mix.get("sink", "") + dialog = Adw.AlertDialog( + heading="Delete mix?", + body=f"“{name}” and its levels for every source are deleted, " + f"and the “{description}” audio device disappears. " + f"Anything recording or listening to it — OBS, Discord — " + f"loses that input until it is pointed somewhere else.", + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("delete", "Delete") + dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.choose( + self, None, lambda d, r: self._on_remove_mix_response(d, r, mix_id), + ) - def _refresh_outputs_once(self): - self._refresh_outputs() - return GLib.SOURCE_REMOVE + def _on_remove_mix_response(self, dialog, result, mix_id): + if dialog.choose_finish(result) != "delete": + return + if mix_id not in self._mixes: + return + # A slider left mid-drag has a pending _flush_cell_volume timeout that + # would call set_cell and resurrect the very keys remove_mix purges. + for key in [k for k in self._cell_debounce_ids if k[1] == mix_id]: + GLib.source_remove(self._cell_debounce_ids.pop(key)) + # Column first, so nothing can drive a mix that is going away; then the + # mixer, which captures the sink name before dropping the definition and + # on its worker tears every loopback down before destroying the sink; + # then the definition and the generated config catch up. + self.matrix.remove_mix(mix_id) + self.mixer.remove_mix(mix_id) + self._mixes = mixes_module.remove(self._mixes, mix_id) + self._usb_async(lambda defs=dict(self._mixes): setup.install_mixes(defs)) def _on_lowz_changed(self, row, _pspec): if self._updating_ui or not self.dev.connected: diff --git a/wavexlr/mixdialog.py b/wavexlr/mixdialog.py new file mode 100644 index 0000000..6add745 --- /dev/null +++ b/wavexlr/mixdialog.py @@ -0,0 +1,166 @@ +"""Create / rename a mix — a single-page name + icon dialog. + +Modelled on sourcedialog.AddSourceDialog, but one page instead of two: there +is nothing to pick first, so Cancel has to live on this page's own header bar +rather than on a preceding picker page. +""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gtk, Adw, GObject, Pango # noqa: E402 + +from .mixes import DEFAULT_ICON + +ICON_CHOICES = ( + ("audio-headphones-symbolic", "Headphones"), + ("audio-speakers-symbolic", "Speakers"), + ("system-users-symbolic", "Chat"), + ("media-record-symbolic", "Record"), + ("camera-video-symbolic", "Stream"), + ("applications-games-symbolic", "Games"), + ("audio-x-generic-symbolic", "Music"), + ("microphone-sensitivity-high-symbolic", "Mic"), + ("audio-card-symbolic", "Audio"), + ("applications-multimedia-symbolic", "Media"), + ("network-transmit-symbolic", "Send"), + ("multimedia-player-symbolic", "Player"), +) + + +class MixDialog(Adw.Dialog): + """Name + icon for a new or existing mix. + + Deliberately does not offer the sink or the PipeWire description: those are + what other applications bind to, and mixes.update() refuses to change the + sink at all. Renaming here is a display-only change. + """ + + __gsignals__ = { + # (display_name, icon_name) + "mix-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str)), + } + + def __init__(self, *, heading="Add Mix", confirm_label="Add Mix", + name="", icon_name=DEFAULT_ICON): + super().__init__() + self.set_title(heading) + self.set_content_width(460) + self.set_content_height(430) + + self._selected_icon = icon_name or DEFAULT_ICON + + self._nav = Adw.NavigationView() + self.set_child(self._nav) + self._nav.push(self._build_page(heading, confirm_label, name)) + + def _build_page(self, heading, confirm_label, name): + page = Adw.NavigationPage(title=heading) + + view = Adw.ToolbarView() + page.set_child(view) + + header = Adw.HeaderBar() + view.add_top_bar(header) + + # Single-page dialog: unlike sourcedialog's config page, there is no + # picker page behind this one to carry Cancel. + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.connect("clicked", lambda _: self.close()) + header.pack_start(cancel_btn) + + self._confirm_btn = Gtk.Button(label=confirm_label) + self._confirm_btn.add_css_class("suggested-action") + self._confirm_btn.connect("clicked", self._on_confirm) + header.pack_end(self._confirm_btn) + + scroll = Gtk.ScrolledWindow(vexpand=True) + view.set_content(scroll) + + clamp = Adw.Clamp( + maximum_size=420, + margin_start=12, margin_end=12, margin_top=12, margin_bottom=12, + ) + scroll.set_child(clamp) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + clamp.set_child(outer) + + name_group = Adw.PreferencesGroup(title="Name") + outer.append(name_group) + + self._name_row = Adw.EntryRow(title="Mix name") + self._name_row.set_text(name) + self._name_row.connect("changed", self._on_name_changed) + self._name_row.connect("entry-activated", self._on_confirm) + name_group.add(self._name_row) + + hint = Gtk.Label( + label="The name is OpenWave's own label. The audio device other " + "applications record from keeps the name it was created " + "with, so renaming never breaks an OBS or Discord setup.", + wrap=True, xalign=0, + ) + hint.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + hint.add_css_class("dim-label") + hint.add_css_class("caption") + outer.append(hint) + + icon_group = Adw.PreferencesGroup(title="Icon") + outer.append(icon_group) + + flow = Gtk.FlowBox( + selection_mode=Gtk.SelectionMode.SINGLE, + max_children_per_line=6, + min_children_per_line=4, + column_spacing=6, + row_spacing=6, + margin_start=4, margin_end=4, margin_top=8, margin_bottom=8, + homogeneous=True, + ) + flow.add_css_class("openwave-icon-picker") + + # A stored icon outside the offered set (hand-edited mixdefs.json, or a + # future default) is appended rather than silently swapped on save. + choices = list(ICON_CHOICES) + if self._selected_icon not in [icon for icon, _ in choices]: + choices.append((self._selected_icon, "Current")) + + preselect = None + for icon, tooltip in choices: + img = Gtk.Image.new_from_icon_name(icon) + img.set_pixel_size(28) + child = Gtk.FlowBoxChild() + child.set_child(img) + child.set_tooltip_text(tooltip) + child._icon_name = icon # noqa: SLF001 + flow.append(child) + if icon == self._selected_icon: + preselect = child + flow.connect("selected-children-changed", self._on_icon_selected) + icon_group.add(flow) + + if preselect is not None: + flow.select_child(preselect) + + self._sync_confirm() + return page + + def _on_name_changed(self, _row): + self._sync_confirm() + + def _sync_confirm(self): + self._confirm_btn.set_sensitive(bool(self._name_row.get_text().strip())) + + def _on_icon_selected(self, flow): + sel = flow.get_selected_children() + if sel: + self._selected_icon = getattr(sel[0], "_icon_name", self._selected_icon) + + def _on_confirm(self, _widget): + name = self._name_row.get_text().strip() + if not name: + return + self.emit("mix-confirmed", name, self._selected_icon) + self.close() diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 91f8827..5996dc2 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -200,6 +200,16 @@ def _default_sink_name(): return r.stdout.strip() or None +def default_sink_name(): + """The system default sink's node.name, or None. + + Public wrapper so a caller resolving several mixes at once can pay for the + `pactl get-default-sink` call once and hand it to resolve_output, instead + of resolve_output re-running it per mix. + """ + return _default_sink_name() + + def list_audio_streams(): """Return [{id, app_name, media_name, node_name}, ...] for active output streams.""" import json as _json @@ -344,7 +354,15 @@ def cells(self): return {k: v for k, v in self._state.items() if "." in k} def _default_output_for(self, mix_id): - return OUTPUT_AUTO if mix_id == _MONITORING_MIX_ID else OUTPUT_NONE + """Only the first mix monitors by default. + + Keying this to the literal id "personal" was safe while the built-in + mixes could not be removed. They can be now, and deleting that one + would otherwise leave nothing monitored by default. Insertion order is + column order, so the first mix is the leftmost one. + """ + first = next(iter(self._mixes), None) or _MONITORING_MIX_ID + return OUTPUT_AUTO if mix_id == first else OUTPUT_NONE def get_output(self, mix_id): """The persisted choice for a mix: a sink name, OUTPUT_AUTO or OUTPUT_NONE.""" @@ -568,6 +586,33 @@ def remove_source(self, source_id): lambda sid=source_id: self._do_remove_source(sid), ) + def remove_mix(self, mix_id): + """Forget a mix: purge its persisted state now, tear its audio down + on the worker. + + The sink name is read here, before the definition is dropped, because + the worker needs it to destroy the live node and _mix_sink() would + already return None by the time the task runs. + """ + with self._lock: + sink = self._mix_sink(mix_id) + # Cell keys are exactly "." — split rather than match a + # suffix so a source id that happens to end in the mix id survives. + for cell_key in [ + k for k in self._state + if "." in k and k.rsplit(".", 1)[1] == mix_id + ]: + del self._state[cell_key] + outputs = self._state.get(OUTPUTS_STATE_KEY) + if isinstance(outputs, dict): + outputs.pop(mix_id, None) + self._save_state() + self._mixes.pop(mix_id, None) + self._enqueue( + ("remove_mix", mix_id), + lambda mid=mix_id, snk=sink: self._do_remove_mix(mid, snk), + ) + def poll_streams(self): """Refresh the active-stream cache; reconcile on worker if anything moved. @@ -627,6 +672,29 @@ def _do_remove_source(self, source_id): for k in keys: self._destroy_loopback(k) + def _do_remove_mix(self, mix_id, sink_name): + """Worker-side: every loopback touching the mix, then the sink itself. + + Order matters. Destroying the sink while loopbacks still feed it leaves + those pw-loopback children alive and reconnecting against a node that + no longer exists, so they go first. + + Every proc key is shaped ("output", mix), ("mic", mix) or + (source, mix, stream) — the mix id is index 1 in all three. + """ + with self._lock: + keys = [ + k for k in self._procs + if isinstance(k, tuple) and len(k) >= 2 and k[1] == mix_id + ] + for key in keys: + self._destroy_loopback(key) + if sink_name: + # Deferred: keeps mixer's module-level imports free of setup, which + # already reaches back into this package the same way. + from . import setup as setup_module + setup_module.destroy_mix_sink(sink_name) + @staticmethod def _sweep_stale_loopbacks(): try: diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 8903be8..6b86390 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -9,7 +9,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw, GObject # noqa: E402 +from gi.repository import Gtk, Adw, GObject, Pango # noqa: E402 class MixMatrix(Gtk.Box): @@ -18,8 +18,17 @@ class MixMatrix(Gtk.Box): __gsignals__ = { "add-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "remove-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "add-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), + "rename-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "remove-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + # (mix_id, output name — a sink node.name, OUTPUT_AUTO or OUTPUT_NONE) + "mix-output-changed": (GObject.SignalFlags.RUN_FIRST, None, (str, str)), } + # Shown instead of deleting the only mix. The matrix's whole geometry is + # sources × mixes; with no column left there is nothing to route into. + LAST_MIX_REASON = "OpenWave needs at least one mix." + def __init__(self): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.add_css_class("openwave-matrix") @@ -44,15 +53,19 @@ def __init__(self): self._mix_ids = [] self._source_ids = [] self._sources = {} + self._headers = {} self._cells = {} corner = Gtk.Box() corner.set_size_request(260, 64) self._grid.attach(corner, 0, 0, 1, 1) - # "+ Add Source" trailing affordance, lives below the grid + # "+ Add Source" / "+ Add Mix" trailing affordances, below the grid. + # The mix button sits here rather than in a trailing grid column so + # that adding and removing columns never has to renumber it. add_row = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, margin_start=12, margin_end=12, margin_bottom=12, ) wrapper.append(add_row) @@ -65,11 +78,84 @@ def __init__(self): self._add_btn.connect("clicked", lambda _: self.emit("add-source-clicked")) add_row.append(self._add_btn) + self._add_mix_btn = Gtk.Button( + label="+ Add Mix", + halign=Gtk.Align.START, + ) + self._add_mix_btn.add_css_class("openwave-add-mix") + self._add_mix_btn.set_size_request(220, -1) + self._add_mix_btn.connect("clicked", lambda _: self.emit("add-mix-clicked")) + add_row.append(self._add_mix_btn) + def add_mix(self, mix_id, *, title, subtitle, icon_name): + if mix_id in self._mix_ids: + return self._headers[mix_id] col = len(self._mix_ids) + 1 header = MixHeaderCell(title=title, subtitle=subtitle, icon_name=icon_name) + header.connect( + "output-changed", + lambda _h, name, mid=mix_id: self.emit("mix-output-changed", mid, name), + ) + header.connect( + "rename-clicked", lambda _h, mid=mix_id: self.emit("rename-mix-clicked", mid), + ) + header.connect( + "remove-clicked", lambda _h, mid=mix_id: self.emit("remove-mix-clicked", mid), + ) self._grid.attach(header, col, 0, 1, 1) self._mix_ids.append(mix_id) + self._headers[mix_id] = header + + # A mix added after the rows exist still needs a cell in every row. + for row_idx, source_id in enumerate(self._source_ids): + cell = MixCell() + self._grid.attach(cell, col, row_idx + 1, 1, 1) + self._cells[(source_id, mix_id)] = cell + + self._sync_delete_sensitivity() + return header + + def remove_mix(self, mix_id): + if mix_id not in self._mix_ids: + return + idx = self._mix_ids.index(mix_id) + # Column mirror of remove_source's remove_row: Gtk.Grid shifts every + # column to the right of this one left by one, so the list index of the + # remaining mixes stays exactly their grid column minus one. + self._grid.remove_column(idx + 1) + self._mix_ids.pop(idx) + self._headers.pop(mix_id, None) + for source_id in self._source_ids: + self._cells.pop((source_id, mix_id), None) + self._sync_delete_sensitivity() + + def _sync_delete_sensitivity(self): + """Grey out Delete on every header while only one mix is left.""" + enabled = len(self._mix_ids) > 1 + for header in self._headers.values(): + header.set_delete_enabled(enabled, self.LAST_MIX_REASON) + + def set_mix(self, mix_id, *, title=None, subtitle=None, icon_name=None): + """Live-update a header's identity after a rename.""" + header = self._headers.get(mix_id) + if header is None: + return + if title is not None: + header.set_title(title) + if subtitle is not None: + header.set_subtitle(subtitle) + if icon_name is not None: + header.set_icon(icon_name) + + def set_mix_outputs(self, mix_id, entries, current, summary, monitored=True): + """Refresh one header's output chooser and the routing it displays. + + `entries` is [(output name, label), ...] in menu order; `current` is + the persisted choice; `summary` is the short text shown on the header. + """ + header = self._headers.get(mix_id) + if header is not None: + header.set_outputs(entries, current, summary, monitored) def add_source(self, source_id, *, name, icon_name, has_level=False, removable=False): row = len(self._source_ids) + 1 @@ -111,7 +197,18 @@ def cell(self, source_id, mix_id): class MixHeaderCell(Gtk.Box): - """Column header at the top of each mix.""" + """Column header at the top of each mix: identity, routing, and its menu. + + The menu is a Gtk.Popover of ordinary widgets rather than a Gio.Menu: the + output list changes with the hardware and differs per mix, and a Gio.Menu + would mean installing and tearing down a set of Gio actions per column. + """ + + __gsignals__ = { + "output-changed": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "rename-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), + "remove-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), + } def __init__(self, *, title, subtitle, icon_name): super().__init__( @@ -120,36 +217,235 @@ def __init__(self, *, title, subtitle, icon_name): ) self.add_css_class("openwave-mix-header") self.add_css_class("card") - self.set_size_request(220, 64) + # Taller than the 64px data cells because a third line — the live + # output — is worth seeing without opening the menu. Only row 0 grows; + # the corner box beside it simply stretches to match. + self.set_size_request(220, 78) + + self._updating = False + self._current_output = None inner = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin_start=14, - margin_end=14, - margin_top=10, - margin_bottom=10, + margin_end=6, + margin_top=8, + margin_bottom=8, hexpand=True, ) self.append(inner) - icon = Gtk.Image.new_from_icon_name(icon_name) - icon.set_pixel_size(22) - inner.append(icon) + self._icon = Gtk.Image.new_from_icon_name(icon_name) + self._icon.set_pixel_size(22) + inner.append(self._icon) text = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, spacing=2, hexpand=True, valign=Gtk.Align.CENTER + orientation=Gtk.Orientation.VERTICAL, spacing=1, hexpand=True, + valign=Gtk.Align.CENTER, ) inner.append(text) - title_lbl = Gtk.Label(label=title, xalign=0) - title_lbl.add_css_class("heading") - text.append(title_lbl) + # max_width_chars is what actually caps the label: an ellipsizing GTK + # label still requests its full natural width without it, and + # set_size_request(220, …) is a minimum, so a long user-typed name + # would otherwise stretch the whole column. width_chars pins the + # natural width to the same value so every column comes out identical + # regardless of how long or short its name happens to be. + self._title_lbl = Gtk.Label(label=title, xalign=0) + self._title_lbl.set_ellipsize(Pango.EllipsizeMode.END) + self._title_lbl.set_width_chars(14) + self._title_lbl.set_max_width_chars(14) + self._title_lbl.add_css_class("heading") + self._title_lbl.set_tooltip_text(title) + text.append(self._title_lbl) + + self._subtitle_lbl = Gtk.Label(label=subtitle, xalign=0) + self._subtitle_lbl.set_ellipsize(Pango.EllipsizeMode.END) + self._subtitle_lbl.set_width_chars(16) + self._subtitle_lbl.set_max_width_chars(16) + self._subtitle_lbl.add_css_class("dim-label") + self._subtitle_lbl.add_css_class("caption") + self._subtitle_lbl.set_visible(bool(subtitle)) + text.append(self._subtitle_lbl) + + # Hidden until the app has resolved the routing, so the header never + # shows a placeholder that reads like a real device. + self._out_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self._out_box.set_visible(False) + text.append(self._out_box) + + self._out_icon = Gtk.Image.new_from_icon_name("audio-speakers-symbolic") + self._out_icon.set_pixel_size(12) + self._out_icon.add_css_class("dim-label") + self._out_box.append(self._out_icon) + + self._out_lbl = Gtk.Label(label="", xalign=0, hexpand=True) + self._out_lbl.set_ellipsize(Pango.EllipsizeMode.END) + self._out_lbl.set_width_chars(16) + self._out_lbl.set_max_width_chars(16) + self._out_lbl.add_css_class("dim-label") + self._out_lbl.add_css_class("caption") + self._out_box.append(self._out_lbl) + + self._menu_btn = Gtk.MenuButton( + icon_name="view-more-symbolic", + valign=Gtk.Align.CENTER, + tooltip_text="Output, rename, delete", + ) + self._menu_btn.add_css_class("flat") + self._menu_btn.add_css_class("circular") + self._menu_btn.set_popover(self._build_popover()) + inner.append(self._menu_btn) + + # ----- popover ----- + @staticmethod + def _menu_row_button(icon_name, label, label_css=None): + btn = Gtk.Button(hexpand=True) + btn.add_css_class("flat") + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row.append(Gtk.Image.new_from_icon_name(icon_name)) + lbl = Gtk.Label(label=label, xalign=0, hexpand=True) + if label_css: + lbl.add_css_class(label_css) + row.append(lbl) + btn.set_child(row) + return btn + + def _build_popover(self): + pop = Gtk.Popover() + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=8, + margin_start=8, margin_end=8, margin_top=8, margin_bottom=8, + ) + box.set_size_request(272, -1) + pop.set_child(box) + + heading = Gtk.Label(label="Output", xalign=0) + heading.add_css_class("heading") + box.append(heading) + + scroll = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, + vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, + propagate_natural_height=True, + max_content_height=260, + ) + box.append(scroll) + + self._out_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) + self._out_list.add_css_class("boxed-list") + self._out_list.connect("row-selected", self._on_output_row_selected) + scroll.set_child(self._out_list) + + box.append(Gtk.Separator()) + + rename_btn = self._menu_row_button("document-edit-symbolic", "Rename Mix…") + rename_btn.connect("clicked", self._on_rename_clicked) + box.append(rename_btn) + + # The tooltip hangs off a sensitive wrapper as well as the button: + # an insensitive GTK4 widget is skipped by picking and never gets the + # motion event that would show its own tooltip. + self._delete_wrap = Gtk.Box() + box.append(self._delete_wrap) + self._delete_btn = self._menu_row_button( + "user-trash-symbolic", "Delete Mix", label_css="error", + ) + self._delete_btn.connect("clicked", self._on_delete_clicked) + self._delete_wrap.append(self._delete_btn) + + # Belt and braces for the tooltip: a disabled button with no visible + # explanation reads as a bug. + self._delete_hint = Gtk.Label(label="", xalign=0, wrap=True, visible=False) + self._delete_hint.add_css_class("dim-label") + self._delete_hint.add_css_class("caption") + box.append(self._delete_hint) + + return pop - subtitle_lbl = Gtk.Label(label=subtitle, xalign=0) - subtitle_lbl.add_css_class("dim-label") - subtitle_lbl.add_css_class("caption") - text.append(subtitle_lbl) + def _popdown(self): + pop = self._menu_btn.get_popover() + if pop is not None: + pop.popdown() + + def _on_output_row_selected(self, _box, row): + if self._updating or row is None: + return + name = getattr(row, "_output_name", None) + # GTK re-emits row-selected when the popover is first mapped, because + # the selection made on the unrealised list is re-applied then. Compare + # against the value we last displayed rather than trusting the signal: + # re-picking the current output is a no-op either way. + if name is None or name == self._current_output: + return + self._current_output = name + self._popdown() + self.emit("output-changed", name) + + def _on_rename_clicked(self, _btn): + self._popdown() + self.emit("rename-clicked") + + def _on_delete_clicked(self, _btn): + self._popdown() + self.emit("remove-clicked") + + # ----- setters ----- + def set_title(self, title): + self._title_lbl.set_label(title) + self._title_lbl.set_tooltip_text(title) + + def set_subtitle(self, subtitle): + self._subtitle_lbl.set_label(subtitle or "") + self._subtitle_lbl.set_visible(bool(subtitle)) + + def set_icon(self, icon_name): + self._icon.set_from_icon_name(icon_name) + + def set_outputs(self, entries, current, summary, monitored=True): + """Rebuild the chooser. `entries` is [(output name, label), ...].""" + self._updating = True + try: + child = self._out_list.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self._out_list.remove(child) + child = nxt + selected = None + for name, label in entries: + row = Gtk.ListBoxRow() + lbl = Gtk.Label( + label=label, xalign=0, + margin_start=12, margin_end=12, margin_top=8, margin_bottom=8, + ) + lbl.set_ellipsize(Pango.EllipsizeMode.END) + lbl.set_max_width_chars(28) + row.set_child(lbl) + row._output_name = name # noqa: SLF001 + self._out_list.append(row) + if name == current: + selected = row + if selected is not None: + self._out_list.select_row(selected) + self._current_output = current + finally: + self._updating = False + + self._out_lbl.set_label(summary) + self._out_lbl.set_tooltip_text(summary) + self._out_icon.set_from_icon_name( + "audio-speakers-symbolic" if monitored else "audio-volume-muted-symbolic" + ) + self._out_box.set_visible(True) + + def set_delete_enabled(self, enabled, reason=""): + self._delete_btn.set_sensitive(enabled) + tip = None if enabled else (reason or None) + self._delete_btn.set_tooltip_text(tip) + self._delete_wrap.set_tooltip_text(tip) + self._delete_hint.set_label(reason or "") + self._delete_hint.set_visible(not enabled) class SourceCell(Gtk.Box): diff --git a/wavexlr/setup.py b/wavexlr/setup.py index 663aa1f..b04ff0a 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -3,6 +3,8 @@ import os import shutil import subprocess +import tempfile +import threading from . import paths, service @@ -141,6 +143,23 @@ def install_wireplumber(): return True +# install_mixes writes one shared file and shells out to pw-cli. Two mix +# operations overlapping would interleave both, so every call serialises here. +_INSTALL_LOCK = threading.Lock() + + +def _spa_str(value): + """Quote a string as a SPA-JSON / PipeWire config value. + + Mix descriptions are typed by the user and reach both the generated config + and a pw-cli argument. An unescaped quote or backslash truncates the + property and corrupts every sink defined after it, and because the config + is regenerated from the stored name, renaming the mix cannot repair it -- + so the escaping belongs at render time, not at creation time. + """ + return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"' + + GENERATED_MARKER = "# GENERATED by OpenWave" MIXES_HEADER = GENERATED_MARKER + """ from ~/.config/openwave/mixdefs.json. @@ -162,7 +181,8 @@ def render_mixes_conf(mixes): " args = {\n" " factory.name = support.null-audio-sink\n" f" node.name = {mix['sink']}\n" - f' node.description = "{mix["description"]}"\n' + " node.description = " + + _spa_str(mix["description"]) + "\n" " media.class = Audio/Sink\n" " audio.position = [ FL FR ]\n" " object.linger = true\n" @@ -193,7 +213,7 @@ def _create_mix_sink_live(name, description): "{ " "factory.name=support.null-audio-sink " f"node.name={name} " - f'node.description="{description}" ' + "node.description=" + _spa_str(description) + " " "media.class=Audio/Sink " "audio.position=[FL FR] " "object.linger=true " @@ -210,6 +230,46 @@ def _create_mix_sink_live(name, description): pass +def destroy_mix_sink(name): + """Destroy every live PipeWire node published under this node.name. + + Two nodes can share a node.name — the config-file sink and one created by + a previous session's pw-cli create-node both carry it — so every match is + destroyed, not just the first one found. + + Shells out to pw-dump and pw-cli with multi-second timeouts. Call this from + the mixer worker thread only; on the GTK main thread it freezes the window + for as long as PipeWire takes to answer. + """ + import json as _json + try: + r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5) + if r.returncode != 0: + return False + objects = _json.loads(r.stdout) + except (FileNotFoundError, subprocess.SubprocessError, _json.JSONDecodeError): + return False + + node_ids = [ + obj["id"] for obj in objects + if obj.get("type") == "PipeWire:Interface:Node" + and obj.get("id") is not None + and (((obj.get("info") or {}).get("props") or {}).get("node.name") == name) + ] + + destroyed = False + for node_id in node_ids: + try: + r = subprocess.run( + ["pw-cli", "destroy", str(node_id)], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.SubprocessError): + continue + destroyed = destroyed or r.returncode == 0 + return destroyed + + def install_mixes(mixes=None): """Write the generated mix config and materialise the sinks. @@ -221,7 +281,12 @@ def install_mixes(mixes=None): mixes = mixes_module.load_seeded() if not mixes: return False + with _INSTALL_LOCK: + return _install_mixes_locked(mixes) + +def _install_mixes_locked(mixes): + """install_mixes' body, run with _INSTALL_LOCK held.""" content = render_mixes_conf(mixes) os.makedirs(os.path.dirname(MIXES_PATH), exist_ok=True) @@ -243,10 +308,17 @@ def install_mixes(mixes=None): _create_mix_sink_live(mix["sink"], mix["description"]) return True - tmp = MIXES_PATH + ".tmp" - with open(tmp, "w") as f: - f.write(content) - os.replace(tmp, MIXES_PATH) + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(MIXES_PATH), prefix=".mixes-") + try: + with os.fdopen(fd, "w") as f: + f.write(content) + os.replace(tmp, MIXES_PATH) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise for mix in mixes.values(): _create_mix_sink_live(mix["sink"], mix["description"]) From 3a41ede92af43d6ba4e0dc5538053342a2a6995a Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:41:31 -0500 Subject: [PATCH 11/99] Add hardware capture devices as matrix sources 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. --- wavexlr/app.py | 99 ++++++++++++++++-- wavexlr/mixer.py | 189 +++++++++++++++++++++++++++++++--- wavexlr/mixmatrix.py | 13 +++ wavexlr/sourcedialog.py | 219 ++++++++++++++++++++++++++++++++++++++-- wavexlr/sources.py | 55 +++++++++- 5 files changed, 543 insertions(+), 32 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 122f141..4a72a38 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -35,6 +35,7 @@ def __init__(self, **kwargs): self._last_state = None self._poll_id = None self._stream_poll_id = None + self._device_poll_countdown = self._DEVICE_POLL_EVERY self._gain_timeout = None self._hp_timeout = None self._mix_timeout = None @@ -52,6 +53,10 @@ def __init__(self, **kwargs): self.mixer.set_mixes(self._mixes) self.mixer.set_sources(self._sources) self.mixer.start() + # The capture snapshot is seeded by _do_start on the worker. Priming + # it here would put a 5-second-timeout pw-dump on the GTK thread during + # window construction; capture_device_present is fail-open, so an + # unseeded snapshot draws rows live rather than dead in the meantime. self._refresh_outputs() self.meter = MeterMonitor() self._meter_targets = {} @@ -713,10 +718,24 @@ def _start_stream_poll(self): GLib.source_remove(self._stream_poll_id) self._stream_poll_id = GLib.timeout_add_seconds(2, self._stream_poll_tick) + # Capture devices change orders of magnitude less often than streams and + # finding out costs its own pw-dump, so check every third stream tick + # (~6 s) rather than adding a second timer with its own teardown. + _DEVICE_POLL_EVERY = 3 + def _stream_poll_tick(self): self.mixer.poll_streams() - for source_id in list(self._sources.keys()): - self._refresh_app_meter(source_id) + self._device_poll_countdown -= 1 + check_devices = self._device_poll_countdown <= 0 + if check_devices: + self._device_poll_countdown = self._DEVICE_POLL_EVERY + self.mixer.request_capture_poll() + for source_id, source in list(self._sources.items()): + if sources_module.kind(source) == sources_module.KIND_DEVICE: + if check_devices: + self._refresh_device_meter(source_id, source) + else: + self._refresh_app_meter(source_id) return True def _start_meters(self): @@ -727,8 +746,49 @@ def _start_meters(self): lambda level: self._set_source_level("mic", level), ) for source_id in self._sources.keys(): + self._refresh_source_meter(source_id) + + def _refresh_source_meter(self, source_id): + """Point a source's meter at whatever currently carries its audio.""" + source = self._sources.get(source_id) + if not source: + return + if sources_module.kind(source) == sources_module.KIND_DEVICE: + self._refresh_device_meter(source_id, source) + else: self._refresh_app_meter(source_id) + def _refresh_device_meter(self, source_id, source): + """Meter a capture device straight off its node, as the mic row does. + + There is no stream to follow — the node *is* the audio — so this is the + same call _start_meters makes for self.mixer.mic, and meter.py needs no + change to serve it. + + _meter_targets holds a node name for a device source where it holds a + stream id for an app source. The two never meet: a value is only ever + compared against another value for the same source_id. + """ + node_name = source.get("node_name") + present = self.mixer.capture_device_present(node_name) + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_available(present, reason="Capture device not connected") + if not present: + # Stop rather than leave pw-cat holding a device that has gone, and + # zero the bar so it does not freeze on its last value. + if self._meter_targets.pop(source_id, None) is not None: + self.meter.stop(source_id) + self._set_source_level(source_id, 0.0) + return + if self._meter_targets.get(source_id) == node_name: + return # already metering this node + self.meter.start( + source_id, node_name, + lambda level, sid=source_id: self._set_source_level(sid, level), + ) + self._meter_targets[source_id] = node_name + def _refresh_app_meter(self, source_id): """Re-point the meter at the first currently-matching stream, or stop it if none match. Called on stream-poll changes and source add.""" @@ -761,14 +821,41 @@ def _set_source_level(self, source_id, level): cell.set_level(level) def _on_add_source_clicked(self, _matrix): - dialog = AddSourceDialog() + dialog = AddSourceDialog(exclude_nodes=self._bound_capture_nodes()) dialog.connect("source-confirmed", self._on_source_confirmed) + dialog.connect("device-source-confirmed", self._on_device_source_confirmed) dialog.present(self) + def _bound_capture_nodes(self): + """Capture nodes that already have a row, so the picker cannot make a + duplicate. The Wave's own mic is in the set: it is the built-in row, + and a second row for it would double the same audio into every mix.""" + nodes = { + source.get("node_name") + for source in self._sources.values() + if sources_module.kind(source) == sources_module.KIND_DEVICE + } + nodes.add(self.mixer.mic) + return {node for node in nodes if node} + def _on_source_confirmed(self, _dialog, name, match_app_name, icon_name): - source = sources_module.new_source( + self._install_source(sources_module.new_source( name=name, match_app_name=match_app_name, icon_name=icon_name, - ) + )) + + def _on_device_source_confirmed(self, _dialog, name, node_name, icon_name): + # Queue the re-snapshot before installing: the reconcile that + # _install_source triggers refuses to wire a node the snapshot has not + # seen, and the worker runs queued tasks in insertion order, so the + # refresh lands first. Doing it synchronously would put a pw-dump on + # the GTK thread in a click handler. + self.mixer.request_capture_poll() + self._install_source(sources_module.new_device_source( + name=name, node_name=node_name, icon_name=icon_name, + )) + + def _install_source(self, source): + """Persist a new source of either kind, give it a row, and wire it up.""" self._sources = sources_module.add(self._sources, source) self.matrix.add_source( source["id"], @@ -781,7 +868,7 @@ def _on_source_confirmed(self, _dialog, name, match_app_name, icon_name): self._wire_cell(source["id"], mix_id) self.mixer.set_sources(self._sources) self.mixer.poll_streams() - self._refresh_app_meter(source["id"]) + self._refresh_source_meter(source["id"]) def _on_remove_source_clicked(self, _matrix, source_id): source = self._sources.get(source_id, {}) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 5996dc2..a52347c 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -19,6 +19,7 @@ import threading import time from threading import Event, Lock +from . import sources _log = logging.getLogger(__name__) @@ -190,6 +191,59 @@ def list_output_sinks(): return out +def list_capture_sources(): + """Return [{name, description, priority}, ...] of hardware capture devices. + + The mirror of list_output_sinks, using the same discriminator for the same + reason: `device.id` is non-null only on a node backed by a real device, so + one test separates a headset microphone from every virtual Audio/Source — + our own mix sources (openwave_*_mix_source) and any null-sink source the + user has configured. Verified against pw-dump on a machine carrying an + Elgato XLR Dock, a SteelSeries Arctis Nova Pro and a generic USB codec: + the three hardware inputs each carry a device.id, the three openwave + virtual sources carry none. + + Monitor sources are excluded for free. A sink's monitor is a set of ports + on the Audio/Sink node, not a node of its own, so it never appears here as + an Audio/Source at all — only pactl synthesises the ".monitor" + names. The name guards below are belt and braces against a future PipeWire + that publishes them as nodes. Keeping monitors out matters for the reason + list_output_sinks keeps virtual sinks out: a mix sink's monitor fed back + into that mix is a feedback loop. + """ + import json as _json + try: + r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5) + if r.returncode != 0: + return [] + objects = _json.loads(r.stdout) + except (FileNotFoundError, subprocess.SubprocessError, _json.JSONDecodeError): + return [] + + out = [] + for obj in objects: + if obj.get("type") != "PipeWire:Interface:Node": + continue + props = (obj.get("info") or {}).get("props") or {} + if props.get("media.class") != "Audio/Source": + continue + if props.get("device.id") is None: + continue + name = props.get("node.name", "") + if not name or name.startswith("openwave_") or name.endswith(".monitor"): + continue + try: + priority = int(props.get("priority.session", 0)) + except (TypeError, ValueError): + priority = 0 + out.append({ + "name": name, + "description": props.get("node.description") or name, + "priority": priority, + }) + out.sort(key=lambda source: source["description"].lower()) + return out + def _default_sink_name(): try: r = subprocess.run( @@ -257,6 +311,11 @@ def __init__(self): self._sources = {} self._mixes = {} self._streams = {} + # node.name set of the hardware capture devices PipeWire currently + # has. _reconcile_capture_cell consults it to decide whether a device + # source can be wired at all. Always *rebound*, never mutated in + # place, so a worker-thread read always sees one whole snapshot. + self._live_captures = frozenset() # _do_start ends with a full reconcile. Reconciling before it would # route cells into sinks it has not yet created or swept, so # set_sources/set_mixes stay silent until it has run once. @@ -625,6 +684,69 @@ def poll_streams(self): if added or removed: self._enqueue(("poll",), self._reconcile_all) return added, removed + def _refresh_live_captures(self): + """Re-snapshot present capture devices. Returns (added, removed) names. + + An empty result is discarded rather than believed. pw-dump failing — a + timeout, a session manager restarting under us — is indistinguishable + from "every capture device vanished", and acting on the latter would + tear down the mic row's loopbacks along with everything else. A machine + with genuinely no capture hardware has nothing for this snapshot to + gate (the `not capture_node` guard already covers "no Wave"), so + keeping the previous value costs nothing and refusing to act on a + transient failure is the safe side to err on. + """ + names = frozenset(source["name"] for source in list_capture_sources()) + if not names: + return set(), set() + with self._lock: + previous = self._live_captures + self._live_captures = names + return set(names) - set(previous), set(previous) - set(names) + + def poll_capture_devices(self): + """Refresh the capture-device snapshot; reconcile if anything moved. + + The counterpart to poll_streams for device sources: a headset powering + off or coming back changes no stream, so without this nothing would + ever notice. Shares poll_streams' ("poll",) enqueue key, so a tick that + sees both kinds of change still costs one reconcile pass. + + Returns (added, removed) node-name sets for the caller's bookkeeping. + """ + added, removed = self._refresh_live_captures() + if added or removed: + self._enqueue(("poll",), self._reconcile_all) + return added, removed + + def request_capture_poll(self): + """Re-snapshot capture devices on the worker, reconciling if it moved. + + The subprocess belongs off the GTK thread: list_capture_sources runs + pw-dump with a 5 second timeout, and this is driven from a GLib + timeout. Shares poll_streams' key so a tick seeing both kinds of + change still costs one reconcile. + """ + self._enqueue(("poll",), self._do_poll_capture_devices) + + def _do_poll_capture_devices(self): + added, removed = self._refresh_live_captures() + if added or removed: + self._reconcile_all() + + def capture_device_present(self, node_name): + """True if `node_name` is a capture device PipeWire currently has. + + Fail-open, deliberately, and identically to the routing gate in + _reconcile_capture_cell: an empty snapshot means "not yet seeded, or + pw-dump failed", not "every device vanished". Reading it fail-closed + here while the gate reads it fail-open made the two disagree -- audio + routed while the row was drawn as dead. + """ + if not node_name: + return False + live = self._live_captures + return not live or node_name in live # ----- worker-side implementations ----- def _do_start(self): @@ -632,6 +754,8 @@ def _do_start(self): self._respawn_all_output_loopbacks() with self._lock: self._streams = {s["id"]: s for s in list_audio_streams()} + # Outside the lock above: _refresh_live_captures takes it itself. + self._refresh_live_captures() self._started = True self._reconcile_all() @@ -743,23 +867,64 @@ def _reconcile_cell(self, source_id, mix_id): f"{source_id}.{mix_id}", {"volume": 0.0, "muted": False} ) if source_id == "mic": - self._reconcile_mic_cell(mix_id, state["volume"], state["muted"]) - else: - self._reconcile_app_cell(source_id, mix_id, state["volume"], state["muted"]) - - def _reconcile_mic_cell(self, mix_id, volume, muted): - if not self.mic: + self._reconcile_capture_cell( + source_id, mix_id, self.mic, state["volume"], state["muted"], + ) return - mix_sink = self._mix_sink(mix_id) - if not mix_sink: + # Read without the lock, exactly as _reconcile_app_cell already does: + # set_sources rebinds this dict rather than mutating it, so worker code + # only ever sees a finished one. + source = self._sources.get(source_id) + if source is not None and sources.kind(source) == sources.KIND_DEVICE: + self._reconcile_capture_cell( + source_id, mix_id, source.get("node_name"), + state["volume"], state["muted"], + ) return - key = ("mic", mix_id) - node_name = f"openwave_loop_mic_to_{mix_id}" - if volume <= 0.0: + self._reconcile_app_cell(source_id, mix_id, state["volume"], state["muted"]) + + @staticmethod + def _capture_loopback_name(source_id, mix_id): + """node.name for a capture→mix loopback. + + The built-in mic keeps its historical name so upgrading does not orphan + a loopback that is already running under it. Source ids are uuid4 hex + and mix ids are [a-z0-9_], so "dev__to_" can collide + neither with the mic form (no source id is the literal "mic") nor with + an app cell's "__" (no source id is the literal + "dev"). Every form keeps the openwave_loop_ prefix that + _sweep_stale_loopbacks pkills. + """ + if source_id == "mic": + return f"openwave_loop_mic_to_{mix_id}" + return f"openwave_loop_dev_{source_id}_to_{mix_id}" + + def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted): + """Wire one capture *node* into one mix sink at a per-cell level. + + Generalises what used to be _reconcile_mic_cell. A hardware capture + device is a Source node, precisely like the Wave's own mic, so the only + things that differ between the built-in mic row and a headset row are + which node name goes in and what the loopback is called. + + A node PipeWire does not currently have cannot be linked, and spawning + anyway is worse than doing nothing: pw-loopback starts fine (the + playback target exists), _link_capture finds no source ports and gives + up, and the resulting live-but-silent process leaves a key in + self._procs that blocks forever the respawn that would fix it when the + device returns. So tear down instead and let the next + poll_capture_devices pass rebuild it. + """ + key = (source_id, mix_id) + node_name = self._capture_loopback_name(source_id, mix_id) + mix_sink = self._mix_sink(mix_id) + live = self._live_captures + absent = bool(live) and capture_node not in live + if not capture_node or not mix_sink or volume <= 0.0 or absent: self._destroy_loopback(key) return if key not in self._procs: - self._spawn_loopback(key, self.mic, mix_sink, node_name) + self._spawn_loopback(key, capture_node, mix_sink, node_name) node_id = _node_id_by_name(node_name) if node_id is not None: _wpctl("set-volume", node_id, f"{volume:.3f}") diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 6b86390..253c741 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -537,6 +537,19 @@ def __init__(self, *, name, icon_name, has_level, removable=False): def set_name(self, name): self._name_lbl.set_label(name) + def set_available(self, available, *, reason="Device not connected"): + """Dim the row when the device behind it is gone. + + The controls stay live on purpose: the level is persisted whether or + not the device is present, so one set while a headset is off takes + effect the moment it comes back. + """ + if available: + self._name_lbl.remove_css_class("dim-label") + self.set_tooltip_text(None) + else: + self._name_lbl.add_css_class("dim-label") + self.set_tooltip_text(reason) def set_volume(self, value): """Update the master slider without firing the changed signal.""" diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index 101e2c4..0987c5d 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -1,4 +1,11 @@ -"""'Add Source' picker — two pages: app picker, then name + icon config.""" +"""'Add Source' picker: source kind, then a per-kind picker, then name + icon. + +Page 0 forks between an application source and a hardware capture device. The +app branch and the device branch share nothing but the final name/icon page, +which is parameterised so each branch supplies its own defaults and confirm +handler; each branch emits its own signal so neither has to know the other +exists. +""" import gi @@ -6,7 +13,7 @@ gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GObject # noqa: E402 -from .mixer import list_audio_streams +from .mixer import list_audio_streams, list_capture_sources ICON_CHOICES = ( ("applications-multimedia-symbolic", "Generic"), @@ -28,9 +35,13 @@ class AddSourceDialog(Adw.Dialog): __gsignals__ = { # (display_name, match_app_name, icon_name) "source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str)), + # (display_name, capture_node_name, icon_name). A second signal rather + # than a `kind` argument on the first: the two flows then share no + # signature, so neither has to be edited when the other changes. + "device-source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str)), } - def __init__(self): + def __init__(self, *, exclude_nodes=()): super().__init__() self.set_title("Add Source") self.set_content_width(480) @@ -39,10 +50,185 @@ def __init__(self): self._nav = Adw.NavigationView() self.set_child(self._nav) + # Capture nodes that already have a matrix row. Keyword-only with a + # default so an existing AddSourceDialog() call site keeps working. + self._exclude_nodes = frozenset(exclude_nodes) self._selected_app = None + self._selected_device = None self._selected_icon = ICON_CHOICES[0][0] - self._nav.push(self._build_picker_page()) + self._nav.push(self._build_type_page()) + + # ------------------------------------------------------------ page 0 + def _build_type_page(self): + """Fork between the source kinds. + + A separate first page rather than a mode switch on the app picker: the + two flows share only the name/icon page, and keeping them in separate + pages means the app picker needs no knowledge of devices at all. + """ + page = Adw.NavigationPage(title="Add Source") + + view = Adw.ToolbarView() + page.set_child(view) + + header = Adw.HeaderBar() + view.add_top_bar(header) + + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.connect("clicked", lambda _: self.close()) + header.pack_start(cancel_btn) + + clamp = Adw.Clamp( + maximum_size=440, + margin_start=12, margin_end=12, margin_top=12, margin_bottom=12, + ) + view.set_content(clamp) + + outer = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=12, + valign=Gtk.Align.START, + ) + clamp.set_child(outer) + + hint = Gtk.Label( + label="What should this row carry into your mixes?", + wrap=True, xalign=0, + ) + hint.add_css_class("dim-label") + outer.append(hint) + + listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) + listbox.add_css_class("boxed-list") + outer.append(listbox) + + app_row = Adw.ActionRow( + title="Application", + subtitle="Follows every stream an app plays, now and later", + activatable=True, + ) + app_row.add_prefix( + Gtk.Image.new_from_icon_name("applications-multimedia-symbolic") + ) + app_row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) + app_row.connect( + "activated", lambda _r: self._nav.push(self._build_picker_page()), + ) + listbox.append(app_row) + + device_row = Adw.ActionRow( + title="Capture Device", + subtitle="A microphone or line input, such as a headset mic", + activatable=True, + ) + device_row.add_prefix( + Gtk.Image.new_from_icon_name("audio-input-microphone-symbolic") + ) + device_row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) + device_row.connect( + "activated", lambda _r: self._nav.push(self._build_device_page()), + ) + listbox.append(device_row) + + return page + + # ------------------------------------------------------- device picker + def _build_device_page(self): + page = Adw.NavigationPage(title="Pick Capture Device") + + view = Adw.ToolbarView() + page.set_child(view) + + header = Adw.HeaderBar() + view.add_top_bar(header) + + self._device_next_btn = Gtk.Button(label="Next") + self._device_next_btn.add_css_class("suggested-action") + self._device_next_btn.set_sensitive(False) + self._device_next_btn.connect("clicked", self._on_device_next) + header.pack_end(self._device_next_btn) + + scroll = Gtk.ScrolledWindow(vexpand=True) + view.set_content(scroll) + + clamp = Adw.Clamp( + maximum_size=440, + margin_start=12, margin_end=12, margin_top=12, margin_bottom=12, + ) + scroll.set_child(clamp) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + clamp.set_child(outer) + + hint = Gtk.Label( + label="Pick a microphone or line input. OpenWave mixes it into each " + "mix at the level you set, alongside the Wave's own mic.", + wrap=True, xalign=0, + ) + hint.add_css_class("dim-label") + outer.append(hint) + + self._device_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE) + self._device_list.add_css_class("boxed-list") + self._device_list.connect("row-selected", self._on_device_row_selected) + outer.append(self._device_list) + + self._populate_devices() + return page + + def _populate_devices(self): + # Already-bound nodes are filtered out rather than shown disabled: the + # Wave's own mic is the built-in row, and a second row for it would + # double the same audio into every mix. + devices = [ + d for d in list_capture_sources() if d["name"] not in self._exclude_nodes + ] + if not devices: + empty = Adw.ActionRow(title="No other capture devices") + empty.set_subtitle( + "Connect a headset or microphone, then open this dialog again" + ) + empty.set_sensitive(False) + self._device_list.append(empty) + return + for device in devices: + row = Adw.ActionRow(title=device["description"]) + # The node name disambiguates two inputs on one card that share a + # description, and is what actually gets persisted. + row.set_subtitle(device["name"]) + row.add_prefix( + Gtk.Image.new_from_icon_name("audio-input-microphone-symbolic") + ) + row._device = device # noqa: SLF001 + self._device_list.append(row) + + def _on_device_row_selected(self, _box, row): + self._selected_device = ( + getattr(row, "_device", None) if row is not None else None + ) + self._device_next_btn.set_sensitive(self._selected_device is not None) + + def _on_device_next(self, _btn): + if not self._selected_device: + return + self._nav.push(self._build_config_page( + default_name=self._selected_device["description"], + default_icon="microphone-sensitivity-high-symbolic", + on_confirm=self._on_device_confirm, + )) + + def _on_device_confirm(self, _btn): + if not self._selected_device: + return + name = ( + self._name_row.get_text().strip() + or self._selected_device["description"] + ) + self.emit( + "device-source-confirmed", + name, self._selected_device["name"], self._selected_icon, + ) + self.close() # ------------------------------------------------------------ page 1 def _build_picker_page(self): @@ -129,7 +315,14 @@ def _on_next(self, _btn): self._nav.push(self._build_config_page()) # ------------------------------------------------------------ page 2 - def _build_config_page(self): + def _build_config_page(self, *, default_name=None, default_icon=None, + on_confirm=None): + """Shared final page for every source flow. + + Every argument defaults to the app-picker behaviour, so an untouched + `self._build_config_page()` call site keeps working verbatim — which + matters, because the manual-app-entry flow lands on this same page. + """ page = Adw.NavigationPage(title="Name and Icon") view = Adw.ToolbarView() @@ -140,7 +333,7 @@ def _build_config_page(self): add_btn = Gtk.Button(label="Add Source") add_btn.add_css_class("suggested-action") - add_btn.connect("clicked", self._on_confirm) + add_btn.connect("clicked", on_confirm or self._on_confirm) header.pack_end(add_btn) scroll = Gtk.ScrolledWindow(vexpand=True) @@ -160,7 +353,7 @@ def _build_config_page(self): outer.append(name_group) self._name_row = Adw.EntryRow(title="Source name") - self._name_row.set_text(self._selected_app or "") + self._name_row.set_text(default_name or self._selected_app or "") name_group.add(self._name_row) # Icon picker @@ -178,6 +371,7 @@ def _build_config_page(self): ) flow.add_css_class("openwave-icon-picker") first_child = None + preselect = None for icon_name, tooltip in ICON_CHOICES: btn = Gtk.Image.new_from_icon_name(icon_name) btn.set_pixel_size(28) @@ -188,12 +382,17 @@ def _build_config_page(self): flow.append(child) if first_child is None: first_child = child + if icon_name == default_icon: + preselect = child flow.connect("selected-children-changed", self._on_icon_selected) icon_group.add(flow) - if first_child is not None: - flow.select_child(first_child) - self._selected_icon = first_child._icon_name # noqa: SLF001 + # Falls back to the first choice, which is what every existing caller + # got and still gets. + chosen = preselect or first_child + if chosen is not None: + flow.select_child(chosen) + self._selected_icon = chosen._icon_name # noqa: SLF001 return page diff --git a/wavexlr/sources.py b/wavexlr/sources.py index 9d5f8bb..8063f5a 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -1,7 +1,16 @@ """User-defined matrix sources, persisted to ~/.config/openwave/sources.json. -Each source binds to a PipeWire `application.name` so any current or future -audio stream from that application gets mixed through the source's row. +Two kinds of source share this store: + +* an *app* source binds to a PipeWire `application.name`, so any current or + future audio stream from that application gets mixed through its row; +* a *device* source binds to the node.name of a hardware capture device — a + headset microphone, a line input — which is a Source node rather than a + stream and so is wired exactly like the Wave's own mic. + +The `kind` field discriminates them. Records written before device sources +existed carry no `kind` at all, and kind() reads those as app sources, so this +file is never rewritten merely to add a discriminator. """ import json @@ -34,16 +43,54 @@ def save(sources): _atomic_write(CONFIG_PATH, sources) -def new_source(*, name, match_app_name, icon_name="applications-multimedia-symbolic"): - """Return a fresh source dict ready to insert into the sources mapping.""" +KIND_APP = "app" +KIND_DEVICE = "device" + +DEFAULT_APP_ICON = "applications-multimedia-symbolic" +DEFAULT_DEVICE_ICON = "audio-input-microphone-symbolic" + + +def kind(source): + """The kind of a source record. + + Records predating device sources have no "kind" key; they are app sources, + which is why this defaults rather than requiring load() to migrate. An + older build reading a file we wrote simply ignores the extra key, so the + store stays readable in both directions. + """ + return (source or {}).get("kind") or KIND_APP + + +def new_source(*, name, match_app_name, icon_name=DEFAULT_APP_ICON): + """Return a fresh app source dict ready to insert into the sources mapping.""" return { "id": uuid.uuid4().hex[:12], + "kind": KIND_APP, "name": name, "match_app_name": match_app_name, "icon_name": icon_name, } +def new_device_source(*, name, node_name, icon_name=DEFAULT_DEVICE_ICON): + """Return a fresh capture-device source bound to a PipeWire source node. + + `node_name` is the node.name of a hardware Audio/Source. It is stored in + preference to the node's numeric id, which PipeWire reassigns on every + replug, and to its description, which is a display string: the ALSA node + name encodes the card and profile and survives a power cycle. `name` is + the user's label for the row and is free to differ, the same split + mixes.py keeps between a mix's `name` and its `sink`. + """ + return { + "id": uuid.uuid4().hex[:12], + "kind": KIND_DEVICE, + "name": name, + "node_name": node_name, + "icon_name": icon_name, + } + + def add(sources, source): sources[source["id"]] = source save(sources) From ce9d5a898bb7cd0fd8a20d906d37ad29eec532a1 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:42:50 -0500 Subject: [PATCH 12/99] Share one stream matcher, and show a source whose app is not running 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. --- wavexlr/app.py | 31 ++++++++++-- wavexlr/mixer.py | 117 ++++++++++++++++++++++++++++++++++++++++--- wavexlr/mixmatrix.py | 43 +++++++++++++++- wavexlr/style.css | 3 ++ 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 4a72a38..4dcb720 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -14,6 +14,7 @@ from .meter import MeterMonitor from .mixer import ( Mixer, list_output_sinks, default_sink_name, OUTPUT_AUTO, OUTPUT_NONE, + claim_streams, stream_matches, ) from .mixdialog import MixDialog from .mixmatrix import MixMatrix @@ -790,23 +791,40 @@ def _refresh_device_meter(self, source_id, source): self._meter_targets[source_id] = node_name def _refresh_app_meter(self, source_id): - """Re-point the meter at the first currently-matching stream, or stop it - if none match. Called on stream-poll changes and source add.""" + """Re-point the meter at the stream the mixer actually routes for this + source, and reflect whether the bound application is playing at all. + Called on stream-poll changes and source add.""" source = self._sources.get(source_id) if not source: return - match = source.get("match_app_name") streams = self.mixer.streams() + # The same claim function the mixer routes by, so the meter can never + # end up watching a stream a different source owns. + claimed = claim_streams(self._sources, streams).get(source_id, set()) candidate = next( - (s for s in streams.values() if s.get("app_name") == match), None, + (s for sid, s in streams.items() if sid in claimed), None, ) current = self._meter_targets.get(source_id) if candidate is None: + # Waiting is set before the early return below: on the steady idle + # path the meter is already stopped, so a set_waiting placed after + # that return would fire once and never again. + if any(stream_matches(source, s) for s in streams.values()): + # The app is playing, but another source claimed the stream + # first — see mixer.claim_streams for why only one may have it. + hint = "Routed by another source" + else: + hint = "Waiting for audio" + self._set_source_waiting(source_id, True, hint) if current is not None: self.meter.stop(source_id) self._meter_targets.pop(source_id, None) self._set_source_level(source_id, 0.0) return + # Likewise before the `already metering` return, which is the steady + # state for a running app and would otherwise leave the row dimmed + # forever after the first tick that found it. + self._set_source_waiting(source_id, False) if current == candidate["id"]: return # already metering this stream self.meter.start( @@ -815,6 +833,11 @@ def _refresh_app_meter(self, source_id): ) self._meter_targets[source_id] = candidate["id"] + def _set_source_waiting(self, source_id, waiting, hint="Waiting for audio"): + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_waiting(waiting, hint) + def _set_source_level(self, source_id, level): cell = self.matrix.source(source_id) if cell is not None: diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index a52347c..d483357 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -298,6 +298,103 @@ def list_audio_streams(): }) return out +# ----- application matching ------------------------------------------------- +# One definition of "does this stream belong to this source", shared by the +# routing path (Mixer._reconcile_app_cell) and the metering path +# (app._refresh_app_meter). The comparison used to be written out at both +# sites; if they drift, a row shows a dead level bar while audio is routing, +# or a moving one while nothing is. + + +def _normalize(value): + """Case-folded, whitespace-collapsed form used for every name comparison.""" + return " ".join(str(value or "").split()).casefold() + + +def _stream_identities(stream): + """The names a stream may legitimately be known by, most specific first. + + application.name comes first because it is what the add-source picker + offers. node.name and the process binary follow because a hand-typed name + rarely reproduces application.name byte for byte: Discord's stream is + application.name "WEBRTC VoiceEngine" with binary "Discord", plenty of apps + set no application.name at all (list_audio_streams already falls back to + node.name), and application.process.binary is sometimes an absolute path, + hence the basename entry. + + Every comparison against these is EXACT equality, never substring or + prefix. "Chrome" as a substring also matches "Chromium", "Chrome Remote + Desktop" and "chrome_crashpad_handler", which would silently route another + process's audio into a live mix that may be feeding OBS or Discord. Case + and whitespace are the only tolerances. + """ + binary = str(stream.get("binary") or "") + return ( + _normalize(stream.get("app_name")), + _normalize(stream.get("node_name")), + _normalize(binary), + _normalize(os.path.basename(binary)), + ) + + +def _match_rank(source, stream, identities=None): + """Index of the identity `source` matches on, or None if it matches none. + + The index doubles as a specificity score for claim_streams' tie-break. + `identities` may be passed in so a caller checking many sources against one + stream normalizes that stream once. + """ + want = _normalize(source.get("match_app_name")) + if not want: + return None + if identities is None: + identities = _stream_identities(stream) + for rank, identity in enumerate(identities): + if identity and identity == want: + return rank + return None + + +def stream_matches(source, stream): + """True if `stream` is one of the streams `source` is bound to.""" + return _match_rank(source, stream) is not None + + +def claim_streams(sources, streams): + """Assign each stream to at most one source. {source_id: {stream_id, ...}}. + + Matching alone is not safe to route by. Two sources can match one stream: + trivially two sources bound to the same application, and now also a source + bound to application.name "Chromium" beside one bound to the binary + "chromium". Both would be routed into the same mix as separate loopbacks — + distinct keys, distinct node names, so nothing errors — and PipeWire sums + them at the sink. Two sample-aligned copies of one stream is 2x amplitude, + +6.02 dB, and since each source's fader is pushed onto its own loopback it + attenuates only its own copy: pulling one source to zero leaves the app + audible 6 dB down, which reads as a broken fader. + + Giving every stream exactly one owner removes that by construction, in the + one place that decides what gets spawned, so a hand-edited sources.json + cannot bypass it. Ownership is deterministic — most specific match wins, + ties broken on source id — so it cannot flip between polls and thrash the + loopbacks. Sources that match nothing get an empty set, never a KeyError. + """ + claims = {source_id: set() for source_id in sources} + for stream_id, stream in streams.items(): + identities = _stream_identities(stream) + best_key = None + best_id = None + for source_id, source in sources.items(): + rank = _match_rank(source, stream, identities) + if rank is None: + continue + key = (rank, str(source_id)) + if best_key is None or key < best_key: + best_key, best_id = key, source_id + if best_id is not None: + claims[best_id].add(stream_id) + return claims + class Mixer: """Manages pw-loopback subprocesses for the matrix's mic row.""" @@ -931,16 +1028,22 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted _wpctl("set-mute", node_id, "1" if muted else "0") def _reconcile_app_cell(self, source_id, mix_id, volume, muted): - source = self._sources.get(source_id) - if not source: + # Snapshot both dicts under the lock: remove_source mutates _sources in + # place from the GTK thread and claim_streams iterates it, so an + # unlocked iteration could raise into _worker_loop's bare except and + # leave this cell unwired. The lock is released before anything below — + # _spawn_loopback/_destroy_loopback must never be called holding it. + with self._lock: + sources = dict(self._sources) + streams = dict(self._streams) + if source_id not in sources: return mix_sink = self._mix_sink(mix_id) if not mix_sink: return - match = source.get("match_app_name") - matching_stream_ids = { - sid for sid, s in self._streams.items() if s.get("app_name") == match - } + # One owner per stream: see claim_streams for why bare matching would + # route a shared stream into this mix twice, at roughly +6 dB. + matching_stream_ids = claim_streams(sources, streams).get(source_id, set()) existing_keys = { k for k in self._procs if len(k) == 3 and k[0] == source_id and k[1] == mix_id @@ -958,7 +1061,7 @@ def _reconcile_app_cell(self, source_id, mix_id, volume, muted): for stream_id in matching_stream_ids: key = (source_id, mix_id, stream_id) node_name = f"openwave_loop_{source_id}_{mix_id}_{stream_id}" - stream_node_name = self._streams.get(stream_id, {}).get("node_name", "") + stream_node_name = streams.get(stream_id, {}).get("node_name", "") if not stream_node_name: continue if key not in self._procs: diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 253c741..dbb0bb7 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -481,9 +481,28 @@ def __init__(self, *, name, icon_name, has_level, removable=False): icon.set_pixel_size(26) inner.append(icon) + text = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=0, + hexpand=True, + valign=Gtk.Align.CENTER, + ) + inner.append(text) + self._name_lbl = Gtk.Label(label=name, xalign=0, hexpand=True, ellipsize=3) self._name_lbl.add_css_class("heading") - inner.append(self._name_lbl) + text.append(self._name_lbl) + + # Second line, kept out of the layout until the bound application stops + # playing, so a running source looks exactly as it did before this + # existed. The name column is narrow, hence the ellipsize + tooltip. + self._status_lbl = Gtk.Label(label="", xalign=0, ellipsize=3, visible=False) + self._status_lbl.add_css_class("dim-label") + self._status_lbl.add_css_class("caption") + text.append(self._status_lbl) + + # None, not False: the first set_waiting call must always apply. + self._waiting = None self._mute_btn = Gtk.ToggleButton(valign=Gtk.Align.CENTER) self._mute_btn.add_css_class("flat") @@ -560,6 +579,28 @@ def set_level(self, value): """Update the audio activity meter (0.0–1.0). No-op if not enabled.""" if self._level is not None: self._level.set_value(max(0.0, min(1.0, value))) + def set_waiting(self, waiting, hint="Waiting for audio"): + """Show or clear the 'bound application is not playing' state. + + A bound-but-idle source should read as waiting, not broken: the row + dims and gains a hint line, but stays interactive so levels can be set + up before the application is launched. + + Called on every stream-poll tick, so it no-ops unless something + actually changed rather than churning the layout twice a second. + """ + waiting = bool(waiting) + state = (waiting, hint if waiting else "") + if state == self._waiting: + return + self._waiting = state + self._status_lbl.set_label(hint if waiting else "") + self._status_lbl.set_visible(waiting) + self.set_tooltip_text(hint if waiting else None) + if waiting: + self.add_css_class("openwave-source-waiting") + else: + self.remove_css_class("openwave-source-waiting") def set_muted(self, muted): """Update the mute toggle without firing its signal.""" diff --git a/wavexlr/style.css b/wavexlr/style.css index faf0754..59b17ba 100644 --- a/wavexlr/style.css +++ b/wavexlr/style.css @@ -35,3 +35,6 @@ .openwave-mix-cell:disabled { opacity: 0.55; } +.openwave-source-waiting { + opacity: 0.55; +} From c0c9478895e12e9280dc3feb4aa8d68f2328ba65 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:47:40 -0500 Subject: [PATCH 13/99] Bind an application that is not running, and edit an existing source 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 "." 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. --- wavexlr/app.py | 50 ++++++++++ wavexlr/mixmatrix.py | 34 +++++-- wavexlr/sourcedialog.py | 213 ++++++++++++++++++++++++++++++---------- wavexlr/sources.py | 19 ++++ 4 files changed, 256 insertions(+), 60 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 4dcb720..fed40b3 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -137,10 +137,12 @@ def _build_ui(self): icon_name=source.get("icon_name", "applications-multimedia-symbolic"), has_level=True, removable=True, + editable=True, ) self.matrix.connect("add-source-clicked", self._on_add_source_clicked) self.matrix.connect("remove-source-clicked", self._on_remove_source_clicked) + self.matrix.connect("edit-source-clicked", self._on_edit_source_clicked) self.matrix.connect("add-mix-clicked", self._on_add_mix_clicked) self.matrix.connect("rename-mix-clicked", self._on_rename_mix_clicked) self.matrix.connect("remove-mix-clicked", self._on_remove_mix_clicked) @@ -886,6 +888,7 @@ def _install_source(self, source): icon_name=source["icon_name"], has_level=True, removable=True, + editable=True, ) for mix_id in self._mixes: self._wire_cell(source["id"], mix_id) @@ -893,6 +896,53 @@ def _install_source(self, source): self.mixer.poll_streams() self._refresh_source_meter(source["id"]) + def _on_edit_source_clicked(self, _matrix, source_id): + source = self._sources.get(source_id) + if source is None: + return + dialog = AddSourceDialog(source=source) + dialog.connect("source-edited", self._on_source_edited) + dialog.present(self) + + def _on_source_edited(self, _dialog, source_id, name, binding, icon_name): + if source_id not in self._sources: + return # removed while the dialog was open + source = self._sources[source_id] + is_device = sources_module.kind(source) == sources_module.KIND_DEVICE + # Snapshot BEFORE update: sources.update mutates the record in place, + # so reading afterwards would always compare a value to itself. + old_binding = source.get("node_name" if is_device else "match_app_name") + + # sources_module.update, never new_source: the id is the prefix of every + # "." cell key, so a fresh id would orphan the levels. + fields = {"name": name, "icon_name": icon_name} + if not is_device: + # A device's binding is its node_name, which the dialog shows but + # does not offer to edit — it is picked from live hardware, and + # `binding` arrives empty for that flow. + fields["match_app_name"] = binding + self._sources = sources_module.update(self._sources, source_id, **fields) + + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_name(name) + cell.set_icon(icon_name) + + if not is_device and binding != old_binding: + # _refresh_app_meter early-returns when the cached target is still + # the current candidate, so a stale entry pointing at the OLD app's + # stream would keep metering the wrong application forever. + self.meter.stop(source_id) + self._meter_targets.pop(source_id, None) + self._set_source_level(source_id, 0.0) + + # poll_streams BEFORE set_sources: it refreshes Mixer._streams inline on + # this thread, so the reconcile set_sources enqueues sees the current + # stream set instead of a cache up to 2 s old. + self.mixer.poll_streams() + self.mixer.set_sources(self._sources) + self._refresh_source_meter(source_id) + def _on_remove_source_clicked(self, _matrix, source_id): source = self._sources.get(source_id, {}) name = source.get("name", "this source") diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index dbb0bb7..72ed3ba 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -18,6 +18,7 @@ class MixMatrix(Gtk.Box): __gsignals__ = { "add-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "remove-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "edit-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "add-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "rename-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "remove-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), @@ -157,12 +158,18 @@ def set_mix_outputs(self, mix_id, entries, current, summary, monitored=True): if header is not None: header.set_outputs(entries, current, summary, monitored) - def add_source(self, source_id, *, name, icon_name, has_level=False, removable=False): + def add_source(self, source_id, *, name, icon_name, has_level=False, + removable=False, editable=False): row = len(self._source_ids) + 1 source = SourceCell( name=name, icon_name=icon_name, - has_level=has_level, removable=removable, + has_level=has_level, removable=removable, editable=editable, ) + if editable: + source.connect( + "edit-clicked", + lambda _s, sid=source_id: self.emit("edit-source-clicked", sid), + ) if removable: source.connect( "remove-clicked", @@ -455,9 +462,10 @@ class SourceCell(Gtk.Box): "volume-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "mute-toggled": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "remove-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), + "edit-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), } - def __init__(self, *, name, icon_name, has_level, removable=False): + def __init__(self, *, name, icon_name, has_level, removable=False, editable=False): super().__init__( orientation=Gtk.Orientation.HORIZONTAL, spacing=10, @@ -477,9 +485,9 @@ def __init__(self, *, name, icon_name, has_level, removable=False): ) self.append(inner) - icon = Gtk.Image.new_from_icon_name(icon_name) - icon.set_pixel_size(26) - inner.append(icon) + self._icon = Gtk.Image.new_from_icon_name(icon_name) + self._icon.set_pixel_size(26) + inner.append(self._icon) text = Gtk.Box( orientation=Gtk.Orientation.VERTICAL, @@ -543,6 +551,17 @@ def __init__(self, *, name, icon_name, has_level, removable=False): self._level.add_offset_value(Gtk.LEVEL_BAR_OFFSET_FULL, 1.00) inner.append(self._level) + if editable: + edit_btn = Gtk.Button( + icon_name="document-edit-symbolic", + valign=Gtk.Align.CENTER, + tooltip_text="Edit source", + ) + edit_btn.add_css_class("flat") + edit_btn.add_css_class("circular") + edit_btn.connect("clicked", lambda _: self.emit("edit-clicked")) + inner.append(edit_btn) + if removable: remove_btn = Gtk.Button( icon_name="window-close-symbolic", @@ -556,6 +575,9 @@ def __init__(self, *, name, icon_name, has_level, removable=False): def set_name(self, name): self._name_lbl.set_label(name) + + def set_icon(self, icon_name): + self._icon.set_from_icon_name(icon_name) def set_available(self, available, *, reason="Device not connected"): """Dim the row when the device behind it is gone. diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index 0987c5d..d8cf638 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -1,10 +1,14 @@ -"""'Add Source' picker: source kind, then a per-kind picker, then name + icon. +"""'Add Source': source kind, then a per-kind picker, then name + icon. Page 0 forks between an application source and a hardware capture device. The -app branch and the device branch share nothing but the final name/icon page, -which is parameterised so each branch supplies its own defaults and confirm -handler; each branch emits its own signal so neither has to know the other -exists. +app branch can also bind an application that is not running, by typing its +name. The branches share only the final name/icon page, which is parameterised +so each supplies its own defaults and confirm handler, and each emits its own +signal so neither has to know the other exists. + +Passing source= opens straight to the config page in edit mode: the pickers +list only what is present right now, and requiring the bound app to be playing +in order to rename its row would be nonsense. """ import gi @@ -14,6 +18,7 @@ from gi.repository import Gtk, Adw, GObject # noqa: E402 from .mixer import list_audio_streams, list_capture_sources +from . import sources as sources_module ICON_CHOICES = ( ("applications-multimedia-symbolic", "Generic"), @@ -35,31 +40,44 @@ class AddSourceDialog(Adw.Dialog): __gsignals__ = { # (display_name, match_app_name, icon_name) "source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str)), - # (display_name, capture_node_name, icon_name). A second signal rather - # than a `kind` argument on the first: the two flows then share no - # signature, so neither has to be edited when the other changes. + # (display_name, capture_node_name, icon_name) "device-source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str)), + # (source_id, display_name, binding, icon_name). `binding` is the + # match_app_name for an app source and "" for a device source, whose + # node_name is hardware and is not editable here. + "source-edited": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str, str)), } - def __init__(self, *, exclude_nodes=()): + def __init__(self, source=None, *, exclude_nodes=()): super().__init__() - self.set_title("Add Source") + self._source = source + self._editing_device = ( + source is not None + and sources_module.kind(source) == sources_module.KIND_DEVICE + ) + self.set_title("Edit Source" if source else "Add Source") self.set_content_width(480) self.set_content_height(560) self._nav = Adw.NavigationView() self.set_child(self._nav) - # Capture nodes that already have a matrix row. Keyword-only with a - # default so an existing AddSourceDialog() call site keeps working. + # Capture nodes that already have a matrix row. self._exclude_nodes = frozenset(exclude_nodes) - self._selected_app = None + # None = nothing picked yet, "" = manual entry, else the picked app. + self._selected_app = None if source is None else source.get("match_app_name") self._selected_device = None - self._selected_icon = ICON_CHOICES[0][0] + self._selected_icon = (source or {}).get("icon_name") or ICON_CHOICES[0][0] - self._nav.push(self._build_type_page()) + if source is None: + self._nav.push(self._build_type_page()) + else: + # Config page as the navigation root; it packs its own Cancel, + # because the type page that normally carries one was never built. + self._nav.push(self._build_config_page( + show_app_row=not self._editing_device, + )) - # ------------------------------------------------------------ page 0 def _build_type_page(self): """Fork between the source kinds. @@ -215,6 +233,10 @@ def _on_device_next(self, _btn): default_name=self._selected_device["description"], default_icon="microphone-sensitivity-high-symbolic", on_confirm=self._on_device_confirm, + # A capture device has no application name, and confirm is gated + # on that row being non-empty: leaving it in would make the device + # flow impossible to complete. + show_app_row=False, )) def _on_device_confirm(self, _btn): @@ -240,10 +262,6 @@ def _build_picker_page(self): header = Adw.HeaderBar() view.add_top_bar(header) - cancel_btn = Gtk.Button(label="Cancel") - cancel_btn.connect("clicked", lambda _: self.close()) - header.pack_start(cancel_btn) - self._next_btn = Gtk.Button(label="Next") self._next_btn.add_css_class("suggested-action") self._next_btn.set_sensitive(False) @@ -263,8 +281,9 @@ def _build_picker_page(self): clamp.set_child(outer) hint = Gtk.Label( - label="Pick an application that's currently playing audio. " - "OpenWave will route any future streams from this app through the new source row.", + label="Pick an application that's currently playing audio, or enter one " + "manually if it isn't running yet. OpenWave will route any future " + "streams from that app through the new source row.", wrap=True, xalign=0, ) hint.add_css_class("dim-label") @@ -287,10 +306,9 @@ def _populate_apps(self): if not apps: empty = Adw.ActionRow(title="No audio streams playing") - empty.set_subtitle("Start playback in an app, then click + Add Source again") + empty.set_subtitle("Start playback in an app, or enter a name manually below") empty.set_sensitive(False) self._listbox.append(empty) - return for app_name in sorted(apps.keys()): row = Adw.ActionRow(title=app_name) @@ -301,29 +319,45 @@ def _populate_apps(self): row._app_name = app_name # noqa: SLF001 self._listbox.append(row) + # Always offered. An app that isn't running publishes no stream, so + # without this row it could never be bound at all -- and with an empty + # list the page is otherwise a dead end, since the placeholder row is + # insensitive and Next stays disabled forever. + manual = Adw.ActionRow(title="Enter manually…") + manual.set_subtitle("Bind an application that isn't running yet") + manual.add_prefix(Gtk.Image.new_from_icon_name("document-edit-symbolic")) + manual._app_name = "" # noqa: SLF001 + self._listbox.append(manual) + def _on_row_selected(self, _box, row): - if row is None: - self._selected_app = None - self._next_btn.set_sensitive(False) - return - self._selected_app = getattr(row, "_app_name", None) - self._next_btn.set_sensitive(self._selected_app is not None) + # "" is the manual row: a real choice, just with nothing prefilled. + # Compare against None, not truthiness, or it reads as "no selection". + app = getattr(row, "_app_name", None) if row is not None else None + self._selected_app = app + self._next_btn.set_sensitive(app is not None) def _on_next(self, _btn): - if not self._selected_app: + if self._selected_app is None: return self._nav.push(self._build_config_page()) # ------------------------------------------------------------ page 2 def _build_config_page(self, *, default_name=None, default_icon=None, - on_confirm=None): + on_confirm=None, show_app_row=True): """Shared final page for every source flow. - Every argument defaults to the app-picker behaviour, so an untouched - `self._build_config_page()` call site keeps working verbatim — which - matters, because the manual-app-entry flow lands on this same page. + Every argument defaults to the app-picker behaviour, so the plain + `self._build_config_page()` call in _on_next keeps working verbatim. + + show_app_row is the one that is not cosmetic. The Application entry is + what makes a not-yet-running app bindable and a mis-bound source + fixable, and confirm is gated on it being non-empty — but a capture + device has no application name at all, so leaving the row in the device + flow would leave confirm permanently insensitive and make device + sources impossible to create. """ - page = Adw.NavigationPage(title="Name and Icon") + editing = self._source is not None + page = Adw.NavigationPage(title="Edit Source" if editing else "Name and Icon") view = Adw.ToolbarView() page.set_child(view) @@ -331,10 +365,17 @@ def _build_config_page(self, *, default_name=None, default_icon=None, header = Adw.HeaderBar() view.add_top_bar(header) - add_btn = Gtk.Button(label="Add Source") - add_btn.add_css_class("suggested-action") - add_btn.connect("clicked", on_confirm or self._on_confirm) - header.pack_end(add_btn) + if editing: + # This page is the navigation root, so NavigationView draws no back + # button and the page that carries Cancel was never built. + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.connect("clicked", lambda _: self.close()) + header.pack_start(cancel_btn) + + self._confirm_btn = Gtk.Button(label="Save" if editing else "Add Source") + self._confirm_btn.add_css_class("suggested-action") + self._confirm_btn.connect("clicked", on_confirm or self._on_confirm) + header.pack_end(self._confirm_btn) scroll = Gtk.ScrolledWindow(vexpand=True) view.set_content(scroll) @@ -353,9 +394,46 @@ def _build_config_page(self, *, default_name=None, default_icon=None, outer.append(name_group) self._name_row = Adw.EntryRow(title="Source name") - self._name_row.set_text(default_name or self._selected_app or "") + self._name_row.set_text( + (self._source or {}).get("name") + or default_name + or self._selected_app + or "" + ) + self._name_row.connect("changed", self._on_binding_changed) name_group.add(self._name_row) + # Application binding — app sources only. + self._app_row = None + if show_app_row: + app_group = Adw.PreferencesGroup( + title="Application", + description="Matched against PipeWire's application.name, its " + "node.name or its process binary, ignoring case " + "and spacing. Check the spelling with: " + "pw-dump | grep application.name", + ) + outer.append(app_group) + + self._app_row = Adw.EntryRow(title="Application name") + self._app_row.set_text(self._selected_app or "") + self._app_row.connect("changed", self._on_binding_changed) + app_group.add(self._app_row) + elif editing: + # A device source's binding is hardware, not text: show it, do not + # offer to edit it. Re-pointing a row at a different capture device + # means adding a new row. + dev_group = Adw.PreferencesGroup(title="Capture Device") + outer.append(dev_group) + dev_row = Adw.ActionRow( + title=self._source.get("node_name", ""), + subtitle="The capture device this row is bound to", + ) + dev_row.add_prefix( + Gtk.Image.new_from_icon_name("audio-input-microphone-symbolic") + ) + dev_group.add(dev_row) + # Icon picker icon_group = Adw.PreferencesGroup(title="Icon") outer.append(icon_group) @@ -370,7 +448,15 @@ def _build_config_page(self, *, default_name=None, default_icon=None, homogeneous=True, ) flow.add_css_class("openwave-icon-picker") - first_child = None + + # One rule covers all three flows, so neither feature needs a fallback + # branch. On a plain add _selected_icon is already ICON_CHOICES[0][0], + # so the first child is preselected exactly as today; the device flow + # asks for Mic; an edit keeps its stored icon, and one no longer + # offered here selects nothing and is preserved rather than being + # silently rewritten just by opening the editor. + if default_icon: + self._selected_icon = default_icon preselect = None for icon_name, tooltip in ICON_CHOICES: btn = Gtk.Image.new_from_icon_name(icon_name) @@ -380,30 +466,49 @@ def _build_config_page(self, *, default_name=None, default_icon=None, child.set_tooltip_text(tooltip) child._icon_name = icon_name # noqa: SLF001 flow.append(child) - if first_child is None: - first_child = child - if icon_name == default_icon: + if icon_name == self._selected_icon: preselect = child flow.connect("selected-children-changed", self._on_icon_selected) icon_group.add(flow) - # Falls back to the first choice, which is what every existing caller - # got and still gets. - chosen = preselect or first_child - if chosen is not None: - flow.select_child(chosen) - self._selected_icon = chosen._icon_name # noqa: SLF001 + if preselect is not None: + flow.select_child(preselect) + self._sync_confirm() return page + def _on_binding_changed(self, _row): + self._sync_confirm() + + def _sync_confirm(self): + """A source that binds nothing can never be metered or routed, so refuse + to create one rather than persisting dead config. With no Application + row (a capture device) the name is the only requirement.""" + if self._app_row is not None: + ok = bool(self._app_row.get_text().strip()) + else: + ok = bool(self._name_row.get_text().strip()) + self._confirm_btn.set_sensitive(ok) + def _on_icon_selected(self, flow): sel = flow.get_selected_children() if sel: self._selected_icon = getattr(sel[0], "_icon_name", self._selected_icon) def _on_confirm(self, _btn): - if not self._selected_app: + # Read the field, not _selected_app: with manual entry the picker's + # value is "" and the entry is the only source of truth. + app = self._app_row.get_text().strip() if self._app_row is not None else "" + if self._source is None and not app: + return + name = self._name_row.get_text().strip() or app + if not name: return - name = self._name_row.get_text().strip() or self._selected_app - self.emit("source-confirmed", name, self._selected_app, self._selected_icon) + if self._source is not None: + # Carry the id so app.py routes this through sources.update() and + # the row keeps its persisted per-mix levels. + self.emit("source-edited", self._source["id"], name, app, + self._selected_icon) + else: + self.emit("source-confirmed", name, app, self._selected_icon) self.close() diff --git a/wavexlr/sources.py b/wavexlr/sources.py index 8063f5a..9f96a0f 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -101,3 +101,22 @@ def remove(sources, source_id): sources.pop(source_id, None) save(sources) return sources + +def update(sources, source_id, **fields): + """Edit a source in place, preserving its id. + + The id is structural: per-cell levels are keyed "." in + ~/.config/openwave/mixes.json and in Mixer's in-memory state, so minting a + new id (as new_source does) would silently orphan every level the user has + set for this row. Editing must come through here, never through + new_source(). + """ + source = sources.get(source_id) + if source is None: + return sources + for key, value in fields.items(): + if key == "id": + continue + source[key] = value + save(sources) + return sources From 32db8346c4345d3934a5086bc75bb2f50678f5bc Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:52:01 -0500 Subject: [PATCH 14/99] Keep a mix playing when the window is closed 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. --- wavexlr/mixer.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index d483357..d1916b5 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -37,6 +37,11 @@ _libc = None +def _is_output_key(key): + """True for a mix's output loopback, which outlives this process.""" + return isinstance(key, tuple) and len(key) == 2 and key[0] == "output" + + def _set_pdeathsig(): if _libc is not None: _libc.prctl(_PR_SET_PDEATHSIG, int(signal.SIGTERM), 0, 0, 0) @@ -588,7 +593,8 @@ def streams(self): return dict(self._streams) # ----- subprocess lifecycle ----- - def _spawn_loopback(self, key, capture_source_name, playback_target, node_name): + def _spawn_loopback(self, key, capture_source_name, playback_target, + node_name, detach=False): """Spawn a pw-loopback and *manually* link the capture side to `capture_source_name`'s output ports. We disable autoconnect on capture because the session manager will otherwise hijack the loopback by @@ -596,6 +602,13 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, node_name): target.object can't be resolved to a Source node — which is exactly the case for null-sink monitors. The link is set up after a brief wait so the node has time to register. + + detach=True leaves the child outside this process's lifetime: no + PR_SET_PDEATHSIG and its own session. That is for the loopbacks that + carry a mix to hardware, which must keep playing when the window is + closed -- the default sink is a null sink, so losing them silences the + whole machine, not just OpenWave. Cell loopbacks stay tied to the + process: they are mixing state, and are rebuilt on the next start. """ if key in self._procs: return @@ -613,7 +626,8 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, node_name): ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - preexec_fn=_set_pdeathsig, + preexec_fn=None if detach else _set_pdeathsig, + start_new_session=detach, ) except (FileNotFoundError, OSError): return @@ -660,8 +674,13 @@ def _destroy_loopback(self, key): pass def _atexit_cleanup(self): - """Fast best-effort tear-down on interpreter exit. No locking, no waits.""" - for proc in list(self._procs.values()): + """Fast best-effort tear-down on interpreter exit. No locking, no waits. + + Output loopbacks are skipped for the same reason stop() skips them. + """ + for key, proc in list(self._procs.items()): + if _is_output_key(key): + continue try: proc.terminate() except (OSError, ProcessLookupError): @@ -687,6 +706,12 @@ def stop(self): pass with self._lock: for key in list(self._procs.keys()): + if _is_output_key(key): + # Deliberately left running; _sweep_stale_loopbacks reclaims + # it on the next start. Tearing it down here would undo the + # detach for every ordinary quit. + self._procs.pop(key, None) + continue self._destroy_loopback(key) def set_cell(self, source_id, mix_id, volume, muted): @@ -867,7 +892,7 @@ def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): if target is None: return self._spawn_loopback( - key, mix_sink, target, f"openwave_loop_out_{mix_id}", + key, mix_sink, target, f"openwave_loop_out_{mix_id}", detach=True, ) def _respawn_all_output_loopbacks(self): From 167ee770c883ea6e549fe852d188f829ed147667 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:54:21 -0500 Subject: [PATCH 15/99] Put the sliders in their cards and collapse the device sidebar 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. --- wavexlr/app.py | 143 ++++++++++++++++++++++++++++++------------------- 1 file changed, 89 insertions(+), 54 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index fed40b3..e512e15 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -26,6 +26,23 @@ KNOB_LABELS = {"gain": "Gain", "hp": "Headphones", "mix": "Monitor Mix"} +def _slider_row(scale): + """Put a Gtk.Scale inside a PreferencesGroup card. + + The scales were appended to the sidebar box rather than added to their + group, so they rendered below the whole card -- visually detached from the + row whose value they set, and ambiguous about which control they belonged + to. + """ + row = Adw.PreferencesRow(activatable=False, selectable=False) + scale.set_margin_start(12) + scale.set_margin_end(12) + scale.set_margin_top(2) + scale.set_margin_bottom(6) + row.set_child(scale) + return row + + class WaveXLRWindow(Adw.ApplicationWindow): def __init__(self, **kwargs): super().__init__(**kwargs, title="OpenWave", default_width=1100, default_height=620) @@ -54,6 +71,10 @@ def __init__(self, **kwargs): self.mixer.set_mixes(self._mixes) self.mixer.set_sources(self._sources) self.mixer.start() + # Re-evaluate now that mixer.hp is known: whether the capture fix is + # needed at all depends on the card exposing a playback side, and the + # first call above ran before the Mixer existed. + self._update_service_status() # The capture snapshot is seeded by _do_start on the worker. Priming # it here would put a 5-second-timeout pw-dump on the GTK thread during # window construction; capture_device_present is fail-open, so an @@ -76,6 +97,27 @@ def _build_ui(self): self.status_label.add_css_class("dim-label") header.set_title_widget(self.status_label) + # Audio-service status. Packed at the start and hidden while healthy, + # so it costs nothing until it has something to say -- it used to be a + # whole PreferencesGroup carrying one row. + self.service_btn = Gtk.MenuButton( + icon_name="dialog-warning-symbolic", visible=False, + ) + self.service_btn.add_css_class("flat") + service_pop = Gtk.Popover() + service_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=8, + margin_top=12, margin_bottom=12, margin_start=12, margin_end=12, + ) + self.service_label = Gtk.Label(label="", xalign=0, wrap=True, max_width_chars=34) + service_box.append(self.service_label) + self.uninstall_btn = Gtk.Button(label="Uninstall capture fix") + self.uninstall_btn.connect("clicked", self._on_uninstall_clicked) + service_box.append(self.uninstall_btn) + service_pop.set_child(service_box) + self.service_btn.set_popover(service_pop) + header.pack_start(self.service_btn) + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic", tooltip_text="Reconnect") refresh_btn.connect("clicked", lambda _: self._try_connect()) header.pack_end(refresh_btn) @@ -167,26 +209,7 @@ def _build_ui(self): self.split.set_sidebar(sidebar_scroll) def _build_device_pane(self, parent): - """Populate the right-hand column with Audio / Mic / HP / Device Info groups.""" - # --- Audio fix status --- - status_group = Adw.PreferencesGroup(title="Audio") - parent.append(status_group) - - self.audio_status_row = Adw.ActionRow( - title="Capture Fix", - subtitle="Keeps mic capture active to prevent the race condition" - ) - self.audio_status_icon = Gtk.Image(icon_name="emblem-ok-symbolic") - self.audio_status_icon.add_css_class("dim-label") - self.audio_status_row.add_suffix(self.audio_status_icon) - - self.uninstall_btn = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Uninstall capture fix") - self.uninstall_btn.add_css_class("flat") - self.uninstall_btn.connect("clicked", self._on_uninstall_clicked) - self.audio_status_row.add_suffix(self.uninstall_btn) - - status_group.add(self.audio_status_row) - + """Populate the sidebar: Microphone, Headphones, and device info.""" # --- Mic controls --- mic_group = Adw.PreferencesGroup(title="Microphone") parent.append(mic_group) @@ -208,10 +231,8 @@ def _build_device_pane(self, parent): draw_value=False, adjustment=Gtk.Adjustment(lower=0x0000, upper=0x5000, step_increment=0x40, page_increment=0x200), ) - self.gain_scale.set_margin_start(12) - self.gain_scale.set_margin_end(12) self.gain_scale.connect("value-changed", self._on_gain_changed) - parent.append(self.gain_scale) + mic_group.add(_slider_row(self.gain_scale)) knob_row = Adw.ActionRow(title="Knob Controls", subtitle="What the physical knob adjusts") self.knob_label = Gtk.Label(label="Gain") @@ -236,10 +257,8 @@ def _build_device_pane(self, parent): draw_value=False, adjustment=Gtk.Adjustment(lower=-60.0, upper=0.0, step_increment=0.5, page_increment=2.0), ) - self.hp_scale.set_margin_start(12) - self.hp_scale.set_margin_end(12) self.hp_scale.connect("value-changed", self._on_hp_changed) - parent.append(self.hp_scale) + hp_group.add(_slider_row(self.hp_scale)) lowz_row = Adw.SwitchRow(title="Low Impedance", subtitle="For low impedance headphones") lowz_row.connect("notify::active", self._on_lowz_changed) @@ -262,57 +281,73 @@ def _build_device_pane(self, parent): ) self.mix_scale.set_margin_start(12) self.mix_scale.set_margin_end(12) - self.mix_scale.set_visible(False) self.mix_scale.connect("value-changed", self._on_mix_changed) - parent.append(self.mix_scale) + self.mix_scale_row = _slider_row(self.mix_scale) + self.mix_scale_row.set_visible(False) + hp_group.add(self.mix_scale_row) # Output routing is per mix and lives in each mix column's header # menu, not here — one device combo could only ever speak for one mix. # --- Device info --- - info_group = Adw.PreferencesGroup(title="Device Info") + # Titleless group so the expander reads as a single collapsed line: it + # is reference material, looked at once, and does not deserve a + # permanent three-row card in a narrow sidebar. + info_group = Adw.PreferencesGroup() parent.append(info_group) + info_expander = Adw.ExpanderRow(title="Device Info") + info_group.add(info_expander) + self.fw_row = Adw.ActionRow(title="Firmware") self.fw_label = Gtk.Label(label="—") self.fw_label.add_css_class("dim-label") self.fw_row.add_suffix(self.fw_label) - info_group.add(self.fw_row) + info_expander.add_row(self.fw_row) - self.api_row = Adw.ActionRow(title="API Version") + self.api_row = Adw.ActionRow(title="API") self.api_label = Gtk.Label(label="—") self.api_label.add_css_class("dim-label") self.api_row.add_suffix(self.api_label) - info_group.add(self.api_row) + info_expander.add_row(self.api_row) self.serial_row = Adw.ActionRow(title="Serial") self.serial_label = Gtk.Label(label="—") self.serial_label.add_css_class("dim-label") self.serial_row.add_suffix(self.serial_label) - info_group.add(self.serial_row) + info_expander.add_row(self.serial_row) def _update_service_status(self): - """Check if the audio service is running.""" - active = service.is_running() - - if active: - self.audio_status_icon.set_from_icon_name("emblem-ok-symbolic") - self.audio_status_icon.remove_css_class("dim-label") - self.audio_status_row.set_subtitle("Audio service running") - self.uninstall_btn.set_visible(True) + """Reflect the audio service in the header, and only when it matters. + + The capture fix works around a firmware race between playback and + capture on the same device. A card with no playback side cannot hit it, + so warning that the service is down is noise there -- which is the + normal state for anyone monitoring through a headset rather than the + Wave's own jack. + """ + if service.is_running(): + self.service_btn.set_visible(False) + return + + needed = bool(getattr(self.mixer, "hp", None)) if hasattr(self, "mixer") else True + if not needed: + self.service_btn.set_visible(False) + return + + if service.is_failed(): + text = "The audio service failed to start." + elif service.is_installed(): + text = "The audio service is installed but not running." else: - self.audio_status_icon.set_from_icon_name("dialog-warning-symbolic") - # Distinguish a service that never came up from one that is not - # installed at all: both leave the capture fix off, but only the - # first has anything to read in `journalctl --user -u openwave`. - if service.is_failed(): - subtitle = "Audio service failed to start" - elif service.is_installed(): - subtitle = "Audio service installed but not running" - else: - subtitle = "Audio service not running" - self.audio_status_row.set_subtitle(subtitle) - self.uninstall_btn.set_visible(False) + text = "The audio service is not running." + self.service_label.set_label( + text + " Without it the microphone can fall silent when playback " + "starts before capture." + ) + self.uninstall_btn.set_visible(service.is_installed()) + self.service_btn.set_tooltip_text(text) + self.service_btn.set_visible(True) def _on_uninstall_clicked(self, btn): dialog = Adw.AlertDialog( @@ -410,7 +445,7 @@ def _apply_profile(self, profile): self.knob_row.set_visible(profile.has_vol_select) self.lowz_row.set_visible(profile.has_low_z) self.mix_row.set_visible(profile.has_monitor_mix) - self.mix_scale.set_visible(profile.has_monitor_mix) + self.mix_scale_row.set_visible(profile.has_monitor_mix) if profile.has_monitor_mix: self.mix_scale.get_adjustment().set_upper(profile.mix_max) self.mic_source.set_name(profile.display_name) From c93ddab25418d5dc38f6f4ca37ffdebc625602f9 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 16:57:01 -0500 Subject: [PATCH 16/99] Say when a mix carries nothing, and stop offering plumbing as applications 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. --- wavexlr/app.py | 16 ++++++++++++++++ wavexlr/mixer.py | 11 +++++++++-- wavexlr/mixes.py | 30 ++++++++++++++++++++++++++++-- wavexlr/mixmatrix.py | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index e512e15..0f5c020 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -83,6 +83,7 @@ def __init__(self, **kwargs): self.meter = MeterMonitor() self._meter_targets = {} self._wire_matrix_cells() + self._refresh_mix_emptiness() self._start_meters() self._start_stream_poll() self._try_connect() @@ -317,6 +318,17 @@ def _build_device_pane(self, parent): self.serial_row.add_suffix(self.serial_label) info_expander.add_row(self.serial_row) + def _refresh_mix_emptiness(self): + """Mark every mix that no source currently feeds.""" + cells = self.mixer.cells() + for mix_id in self._mixes: + fed = any( + state.get("volume", 0.0) > 0.0 and not state.get("muted") + for key, state in cells.items() + if key.rsplit(".", 1)[-1] == mix_id + ) + self.matrix.set_mix_empty(mix_id, not fed) + def _update_service_status(self): """Reflect the audio service in the header, and only when it matters. @@ -609,6 +621,7 @@ def _on_mix_created(self, _dialog, name, icon_name): def _on_mix_installed(self, _ok): self.mixer.set_mixes(self._mixes) self._refresh_outputs() + self._refresh_mix_emptiness() def _on_mix_install_failed(self, exc): """Register the mix anyway, and say that its sink is missing. @@ -930,6 +943,7 @@ def _install_source(self, source): self.mixer.set_sources(self._sources) self.mixer.poll_streams() self._refresh_source_meter(source["id"]) + self._refresh_mix_emptiness() def _on_edit_source_clicked(self, _matrix, source_id): source = self._sources.get(source_id) @@ -1019,11 +1033,13 @@ def _flush_cell_volume(self, source_id, mix_id, value): self._cell_debounce_ids.pop((source_id, mix_id), None) cur = self.mixer.get_cell(source_id, mix_id) self.mixer.set_cell(source_id, mix_id, value, cur["muted"]) + self._refresh_mix_emptiness() return False # one-shot def _on_cell_mute_toggled(self, _cell, muted, source_id, mix_id): cur = self.mixer.get_cell(source_id, mix_id) self.mixer.set_cell(source_id, mix_id, cur["volume"], muted) + self._refresh_mix_emptiness() class WaveXLRApp(Adw.Application): diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index d1916b5..36f5688 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -290,9 +290,16 @@ def list_audio_streams(): if props.get("media.class") != "Stream/Output/Audio": continue app = props.get("application.name") or props.get("node.name") or "Unknown" - # Skip our own loopbacks node_name = props.get("node.name", "") - if node_name.startswith("openwave_"): + # Skip our own loopbacks, and anyone else's. A loopback's playback node + # is a Stream/Output like any other, so "playback.game_output" was + # offered in the Add Source picker as though it were an application -- + # binding one captures whatever is routed through that channel rather + # than a program, which is never what the picker appears to promise. + if node_name.startswith("openwave_") or node_name.startswith("playback."): + continue + # A real application publishes a process binary; a virtual node does not. + if not props.get("application.process.binary") and "." in node_name: continue out.append({ "id": obj["id"], diff --git a/wavexlr/mixes.py b/wavexlr/mixes.py index 09d51fd..5cac49b 100644 --- a/wavexlr/mixes.py +++ b/wavexlr/mixes.py @@ -45,7 +45,7 @@ class Unreadable(Exception): "chat": { "id": "chat", "name": "Chat Mix", - "subtitle": "To voice apps (v0.3.0)", + "subtitle": "Send to voice apps", "description": "OpenWave Chat Mix", "sink": "openwave_chat_mix", "icon_name": "system-users-symbolic", @@ -53,7 +53,7 @@ class Unreadable(Exception): "record": { "id": "record", "name": "Record Mix", - "subtitle": "To OBS / recording (v0.3.0)", + "subtitle": "Send to OBS or a recorder", "description": "OpenWave Record Mix", "sink": "openwave_record_mix", "icon_name": "media-record-symbolic", @@ -89,6 +89,30 @@ def load(): return data +_STALE_SUBTITLES = { + "To voice apps (v0.3.0)": "Send to voice apps", + "To OBS / recording (v0.3.0)": "Send to OBS or a recorder", +} + + +def _clear_stale_subtitles(mixes): + """Replace subtitles promising a version that has since shipped. + + The seeded text named an unreleased version as the reason a mix did + nothing. Those mixes work now, and the text is already persisted in every + existing mixdefs.json, so fixing the seed alone would leave it on screen + forever. Only the exact original strings are touched: anything the user has + since edited is theirs. + """ + changed = False + for mix in mixes.values(): + replacement = _STALE_SUBTITLES.get(mix.get("subtitle")) + if replacement is not None: + mix["subtitle"] = replacement + changed = True + return changed + + def load_seeded(): """Load the store, creating it from DEFAULT_MIXES on first run. @@ -107,6 +131,8 @@ def load_seeded(): if data is None: data = copy.deepcopy(DEFAULT_MIXES) save(data) + elif _clear_stale_subtitles(data): + save(data) return data diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 72ed3ba..47be0e3 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -136,6 +136,11 @@ def _sync_delete_sensitivity(self): for header in self._headers.values(): header.set_delete_enabled(enabled, self.LAST_MIX_REASON) + def set_mix_empty(self, mix_id, empty): + header = self._headers.get(mix_id) + if header is not None: + header.set_empty(empty) + def set_mix(self, mix_id, *, title=None, subtitle=None, icon_name=None): """Live-update a header's identity after a rename.""" header = self._headers.get(mix_id) @@ -410,6 +415,32 @@ def set_subtitle(self, subtitle): def set_icon(self, icon_name): self._icon.set_from_icon_name(icon_name) + def set_empty(self, empty): + """Mark the column as carrying nothing. + + A mix whose cells are all at zero is silent, and looks identical to a + working one: the sink exists, apps can select it, and it plays nothing. + Saying so here is the difference between "misconfigured" and "broken", + which is not otherwise visible anywhere. + """ + if getattr(self, "_empty", None) == empty: + return + self._empty = empty + if empty: + self._out_lbl.set_label("No sources routed") + self._out_lbl.set_tooltip_text( + "Every source is at zero for this mix, so it carries no audio. " + "Raise a slider in this column." + ) + self._out_icon.set_from_icon_name("dialog-information-symbolic") + else: + self._out_lbl.set_label(getattr(self, "_out_summary", "")) + self._out_lbl.set_tooltip_text(None) + self._out_icon.set_from_icon_name( + "audio-speakers-symbolic" if getattr(self, "_monitored", True) + else "audio-volume-muted-symbolic" + ) + def set_outputs(self, entries, current, summary, monitored=True): """Rebuild the chooser. `entries` is [(output name, label), ...].""" self._updating = True @@ -439,12 +470,20 @@ def set_outputs(self, entries, current, summary, monitored=True): finally: self._updating = False + self._monitored = monitored + self._out_summary = summary self._out_lbl.set_label(summary) self._out_lbl.set_tooltip_text(summary) self._out_icon.set_from_icon_name( "audio-speakers-symbolic" if monitored else "audio-volume-muted-symbolic" ) self._out_box.set_visible(True) + if getattr(self, "_empty", False): + # Re-assert after the icon and label above, which would otherwise + # overwrite it: an empty column keeps saying so, because where it + # routes is moot until something feeds it. + self._empty = None + self.set_empty(True) def set_delete_enabled(self, enabled, reason=""): self._delete_btn.set_sensitive(enabled) From f913c41324b66675815c0c2d3748c48444cdd659 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:11:03 -0500 Subject: [PATCH 17/99] Move an application's audio into its source, instead of copying it 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_, 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. --- wavexlr/app.py | 10 ++- wavexlr/mixer.py | 170 ++++++++++++++++++++++++++++++++-------- wavexlr/setup.py | 27 +++++++ wavexlr/sourcedialog.py | 17 +++- wavexlr/sources.py | 27 +++++++ 5 files changed, 213 insertions(+), 38 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 0f5c020..68baf6a 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -960,7 +960,10 @@ def _on_source_edited(self, _dialog, source_id, name, binding, icon_name): is_device = sources_module.kind(source) == sources_module.KIND_DEVICE # Snapshot BEFORE update: sources.update mutates the record in place, # so reading afterwards would always compare a value to itself. - old_binding = source.get("node_name" if is_device else "match_app_name") + old_binding = ( + source.get("node_name") if is_device + else sources_module.format_bindings(source) + ) # sources_module.update, never new_source: the id is the prefix of every # "." cell key, so a fresh id would orphan the levels. @@ -969,7 +972,10 @@ def _on_source_edited(self, _dialog, source_id, name, binding, icon_name): # A device's binding is its node_name, which the dialog shows but # does not offer to edit — it is picked from live hardware, and # `binding` arrives empty for that flow. - fields["match_app_name"] = binding + # Stored as a list; drop the superseded singular key so bindings() + # cannot read a stale value from it. + fields["match_app_names"] = sources_module.parse_bindings(binding) + source.pop("match_app_name", None) self._sources = sources_module.update(self._sources, source_id, **fields) cell = self.matrix.source(source_id) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 36f5688..ad93d43 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -37,6 +37,25 @@ _libc = None +SOURCE_SINK_PREFIX = "openwave_src_" + + +def source_sink_name(source_id): + """The intake sink an application source's streams are moved onto.""" + return f"{SOURCE_SINK_PREFIX}{source_id}" + + +def _move_stream(serial, sink_name): + """Move a stream onto a sink. `serial` is PulseAudio's index for it.""" + try: + subprocess.run( + ["pactl", "move-sink-input", str(serial), sink_name], + capture_output=True, text=True, timeout=3, + ) + except (FileNotFoundError, subprocess.SubprocessError): + pass + + def _is_output_key(key): """True for a mix's output loopback, which outlives this process.""" return isinstance(key, tuple) and len(key) == 2 and key[0] == "output" @@ -303,6 +322,10 @@ def list_audio_streams(): continue out.append({ "id": obj["id"], + # PulseAudio addresses a stream by object.serial, and pactl is the + # only thing that reliably moves one (pw-metadata target.object was + # measured not to). + "serial": props.get("object.serial"), "app_name": app, "media_name": props.get("media.name", ""), "node_name": node_name, @@ -356,13 +379,15 @@ def _match_rank(source, stream, identities=None): `identities` may be passed in so a caller checking many sources against one stream normalizes that stream once. """ - want = _normalize(source.get("match_app_name")) - if not want: + from . import sources as _sources + wanted = {_normalize(name) for name in _sources.bindings(source)} + wanted.discard("") + if not wanted: return None if identities is None: identities = _stream_identities(stream) for rank, identity in enumerate(identities): - if identity and identity == want: + if identity and identity in wanted: return rank return None @@ -425,6 +450,9 @@ def __init__(self): # source can be wired at all. Always *rebound*, never mutated in # place, so a worker-thread read always sees one whole snapshot. self._live_captures = frozenset() + # Intake sinks we have created, so tearing one down costs no subprocess + # when there was never one to tear down. + self._intakes = set() # _do_start ends with a full reconcile. Reconciling before it would # route cells into sinks it has not yet created or swept, so # set_sources/set_mixes stay silent until it has run once. @@ -711,6 +739,13 @@ def stop(self): self._worker.join(timeout=3) except RuntimeError: pass + with self._lock: + source_ids = list(self._sources) + for source_id in source_ids: + # Hand every moved stream back before we go: an intake sink lingers + # by necessity, so leaving one behind would strand the application + # in silence until OpenWave next runs. + self._destroy_source_sink(source_id) with self._lock: for key in list(self._procs.keys()): if _is_output_key(key): @@ -880,6 +915,7 @@ def capture_device_present(self, node_name): # ----- worker-side implementations ----- def _do_start(self): self._sweep_stale_loopbacks() + self._sweep_orphan_source_sinks() self._respawn_all_output_loopbacks() with self._lock: self._streams = {s["id"]: s for s in list_audio_streams()} @@ -917,6 +953,10 @@ def _do_retarget_output(self, mix_id): self._respawn_output_loopback(mix_id) def _do_remove_source(self, source_id): + # Destroy the intake first: it returns any parked stream to the default + # sink, so removing a source hands the application back rather than + # leaving it playing into a sink nothing drains. + self._destroy_source_sink(source_id) with self._lock: keys = [ k for k in self._procs @@ -948,6 +988,21 @@ def _do_remove_mix(self, mix_id, sink_name): from . import setup as setup_module setup_module.destroy_mix_sink(sink_name) + def _sweep_orphan_source_sinks(self): + """Destroy intake sinks with no source behind them. + + They linger by necessity, so a crash leaves them holding whatever + application was parked on them -- silent, because nothing drains an + intake sink but the loopback that died with us. Destroying them here + returns those streams to the default sink. + """ + from . import setup + with self._lock: + known = {source_sink_name(sid) for sid in self._sources} + for name in setup.list_sink_names(SOURCE_SINK_PREFIX): + if name not in known: + setup.destroy_mix_sink(name) + @staticmethod def _sweep_stale_loopbacks(): try: @@ -1060,45 +1115,92 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted _wpctl("set-mute", node_id, "1" if muted else "0") def _reconcile_app_cell(self, source_id, mix_id, volume, muted): - # Snapshot both dicts under the lock: remove_source mutates _sources in - # place from the GTK thread and claim_streams iterates it, so an - # unlocked iteration could raise into _worker_loop's bare except and - # leave this cell unwired. The lock is released before anything below — - # _spawn_loopback/_destroy_loopback must never be called holding it. + """Route an application source into one mix. + + The stream is MOVED onto the source's own intake sink, not copied from + wherever it already plays. Copying left the application still connected + to its original sink, so when that sink was one of our mixes -- which it + normally is, the monitoring mix being the system default -- the audio + arrived twice and this cell's fader could only add a second copy on top + of the untouched original. Pulling it to zero changed nothing audible. + + With the stream moved, the loopback out of the intake sink is the only + path, so the fader is authoritative. + """ with self._lock: sources = dict(self._sources) streams = dict(self._streams) - if source_id not in sources: + source = sources.get(source_id) + if source is None: return mix_sink = self._mix_sink(mix_id) if not mix_sink: return - # One owner per stream: see claim_streams for why bare matching would - # route a shared stream into this mix twice, at roughly +6 dB. - matching_stream_ids = claim_streams(sources, streams).get(source_id, set()) - existing_keys = { - k for k in self._procs - if len(k) == 3 and k[0] == source_id and k[1] == mix_id - } - - # Tear down loopbacks for streams that vanished or for a zeroed cell - for k in list(existing_keys): - if volume <= 0.0 or k[2] not in matching_stream_ids: - self._destroy_loopback(k) + if not self._source_is_routed(source_id): + # Nothing carries this source anywhere. Hand back any stream we + # parked and leave the application on whatever it chose. + self._destroy_loopback((source_id, mix_id)) + if source_id in self._intakes: + self._destroy_source_sink(source_id) + return + + intake = self._ensure_source_sink(source_id, source.get("name", source_id)) + if intake is None: + return + + for stream_id in claim_streams(sources, streams).get(source_id, set()): + stream = streams.get(stream_id) or {} + serial = stream.get("serial") + if serial is not None: + _move_stream(serial, intake) + + # One loopback per (source, mix), not per stream: every stream for this + # source shares the intake sink, so they share the path out of it and + # one volume applies to all of them. + key = (source_id, mix_id) + node_name = f"openwave_loop_{source_id}_{mix_id}" if volume <= 0.0: + self._destroy_loopback(key) return + if key not in self._procs: + self._spawn_loopback(key, intake, mix_sink, node_name) + node_id = _node_id_by_name(node_name) + if node_id is not None: + _wpctl("set-volume", node_id, f"{volume:.3f}") + _wpctl("set-mute", node_id, "1" if muted else "0") - # Spawn (or update volume on) loopbacks for each currently-matching stream - for stream_id in matching_stream_ids: - key = (source_id, mix_id, stream_id) - node_name = f"openwave_loop_{source_id}_{mix_id}_{stream_id}" - stream_node_name = streams.get(stream_id, {}).get("node_name", "") - if not stream_node_name: - continue - if key not in self._procs: - self._spawn_loopback(key, stream_node_name, mix_sink, node_name) - node_id = _node_id_by_name(node_name) - if node_id is not None: - _wpctl("set-volume", node_id, f"{volume:.3f}") - _wpctl("set-mute", node_id, "1" if muted else "0") + def _source_is_routed(self, source_id): + """True if any mix carries this source above zero. + + Moving a stream onto an intake sink that nothing drains would mute the + application outright, so a source routed nowhere is left where it is. + """ + with self._lock: + mix_ids = list(self._mixes) + state = dict(self._state) + return any( + (state.get(f"{source_id}.{mix_id}") or {}).get("volume", 0.0) > 0.0 + for mix_id in mix_ids + ) + + def _ensure_source_sink(self, source_id, description): + """Create the source's intake sink if it is not already live.""" + from . import setup + name = source_sink_name(source_id) + try: + setup.create_null_sink(name, f"OpenWave: {description}") + except Exception: + return None + self._intakes.add(source_id) + return name + + def _destroy_source_sink(self, source_id): + """Remove an intake sink, returning any parked stream to the default. + + Measured: destroying the sink reroutes its streams rather than killing + them, which is what makes moving them safe to undo. + """ + from . import setup + setup.destroy_mix_sink(source_sink_name(source_id)) + self._intakes.discard(source_id) diff --git a/wavexlr/setup.py b/wavexlr/setup.py index b04ff0a..a060ad4 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -205,6 +205,33 @@ def _mix_sink_exists(name): return any(line.split("\t", 2)[1] == name for line in r.stdout.splitlines() if "\t" in line) +def list_sink_names(prefix=""): + """Live sink node names, optionally filtered by prefix.""" + try: + r = subprocess.run( + ["pactl", "list", "short", "sinks"], + capture_output=True, text=True, timeout=3, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return [] + names = [] + for line in r.stdout.splitlines(): + parts = line.split("\t") + if len(parts) > 1 and parts[1].startswith(prefix): + names.append(parts[1]) + return names + + +def create_null_sink(name, description): + """Public name for the null-sink creator: mixes are not its only user. + + Application sources need one each as a stream intake, and they are created + on exactly the same terms -- object.linger is mandatory, because without it + the node dies the moment pw-cli exits. + """ + _create_mix_sink_live(name, description) + + def _create_mix_sink_live(name, description): """Spawn a null sink immediately so it appears without a PipeWire restart.""" if _mix_sink_exists(name): diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index d8cf638..cda05ab 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -65,7 +65,12 @@ def __init__(self, source=None, *, exclude_nodes=()): # Capture nodes that already have a matrix row. self._exclude_nodes = frozenset(exclude_nodes) # None = nothing picked yet, "" = manual entry, else the picked app. - self._selected_app = None if source is None else source.get("match_app_name") + # Every binding, comma-separated: a source can gather more than one + # application, and an edit that showed only the first would silently + # drop the rest on save. + self._selected_app = ( + None if source is None else sources_module.format_bindings(source) + ) self._selected_device = None self._selected_icon = (source or {}).get("icon_name") or ICON_CHOICES[0][0] @@ -415,10 +420,18 @@ def _build_config_page(self, *, default_name=None, default_icon=None, ) outer.append(app_group) - self._app_row = Adw.EntryRow(title="Application name") + self._app_row = Adw.EntryRow(title="Applications") self._app_row.set_text(self._selected_app or "") self._app_row.connect("changed", self._on_binding_changed) app_group.add(self._app_row) + hint = Gtk.Label( + label="Separate several with commas, to gather them under one " + "fader \u2014 two music players, or every game.", + xalign=0, wrap=True, margin_top=6, + ) + hint.add_css_class("dim-label") + hint.add_css_class("caption") + app_group.add(hint) elif editing: # A device source's binding is hardware, not text: show it, do not # offer to edit it. Re-pointing a row at a different capture device diff --git a/wavexlr/sources.py b/wavexlr/sources.py index 9f96a0f..518bb8a 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -61,6 +61,33 @@ def kind(source): return (source or {}).get("kind") or KIND_APP +def bindings(source): + """Every application name this source is bound to. + + Older records carry one `match_app_name` string. Newer ones carry a + `match_app_names` list, so a single row can gather several applications -- + a Music row gathering two players, or a Games row gathering every game, + each with one fader instead of a row apiece. + """ + names = source.get("match_app_names") + if isinstance(names, list): + return [str(n).strip() for n in names if str(n).strip()] + single = source.get("match_app_name") + if isinstance(single, str) and single.strip(): + return [single.strip()] + return [] + + +def parse_bindings(text): + """Split a comma-separated application list, discarding blanks.""" + return [part.strip() for part in str(text).split(",") if part.strip()] + + +def format_bindings(source): + """The bindings as one comma-separated string, for an entry field.""" + return ", ".join(bindings(source)) + + def new_source(*, name, match_app_name, icon_name=DEFAULT_APP_ICON): """Return a fresh app source dict ready to insert into the sources mapping.""" return { From 43df0e30d5cf8f64ad35c11898893c870299138f Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:13:41 -0500 Subject: [PATCH 18/99] Seed System, Game, Music, Browser and Voice sources on first run 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. --- wavexlr/app.py | 2 +- wavexlr/sources.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 68baf6a..2c4cba9 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -62,7 +62,7 @@ def __init__(self, **kwargs): self._cell_debounce_ids = {} # One-shot re-read of the routing after a mix output change settles. self._output_refresh_id = None - self._sources = sources_module.load() + self._sources = sources_module.load_seeded() self._mixes = mixes_module.load_seeded() self._build_ui() diff --git a/wavexlr/sources.py b/wavexlr/sources.py index 518bb8a..b29c064 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -13,6 +13,7 @@ file is never rewritten merely to add a discriminator. """ +import copy import json import os import uuid @@ -39,6 +40,19 @@ def load(): return data +def load_seeded(): + """Load the store, creating it from DEFAULT_SOURCES on first run. + + An existing but empty file is respected: a user who has deleted every row + means it, and reseeding would put them all back on the next launch. + """ + if not os.path.exists(CONFIG_PATH): + seeded = copy.deepcopy(DEFAULT_SOURCES) + save(seeded) + return seeded + return load() + + def save(sources): _atomic_write(CONFIG_PATH, sources) @@ -61,6 +75,59 @@ def kind(source): return (source or {}).get("kind") or KIND_APP +# Seeded on first run so the matrix opens with a usable set of rows rather +# than an empty grid. Every cell starts at zero, so a seeded row routes nothing +# and moves no stream until a fader is raised -- they are suggestions, not +# behaviour. +# +# Names are matched case-insensitively against a stream's application name, +# node name and process binary, so one entry covers a program whose reported +# name differs from its binary (Discord publishes "WEBRTC VoiceEngine" and runs +# as "Discord"; a Proton game reports its own name under a wine binary). +DEFAULT_SOURCES = { + "system": { + "id": "system", "name": "System", "icon_name": "preferences-system-symbolic", + "match_app_names": [ + "gnome-shell", "GNOME Shell", "gsd-media-keys", "plasmashell", + "libcanberra", "canberra-gtk-play", "speech-dispatcher", + "xdg-desktop-portal", "notify-send", + ], + }, + "game": { + "id": "game", "name": "Game", "icon_name": "applications-games-symbolic", + "match_app_names": [ + "steam", "Steam", "steamwebhelper", "lutris", "heroic", + "wine64-preloader", "wine-preloader", "wine", "gamescope", + "RSI Launcher", "bottles", "Minecraft", + ], + }, + "music": { + "id": "music", "name": "Music", "icon_name": "audio-x-generic-symbolic", + "match_app_names": [ + "Spotify", "Tidal", "tidal-hifi", "Rhythmbox", "Lollypop", + "Amberol", "Clementine", "Strawberry", "Audacious", "Elisa", + "Deezer", "Feishin", "mpv", "VLC media player", + ], + }, + "browser": { + "id": "browser", "name": "Browser", "icon_name": "web-browser-symbolic", + "match_app_names": [ + "Firefox", "firefox", "LibreWolf", "Zen Browser", "zen", + "Chromium", "Google Chrome", "chrome", "Brave", "brave", + "Vivaldi", "Epiphany", "GNOME Web", + ], + }, + "voice": { + "id": "voice", "name": "Voice", "icon_name": "system-users-symbolic", + "match_app_names": [ + "Discord", "discord", "Vesktop", "vesktop", "WEBRTC VoiceEngine", + "TeamSpeak", "ts3client", "Mumble", "Element", "Signal", + "Telegram", "Zoom", "Slack", + ], + }, +} + + def bindings(source): """Every application name this source is bound to. From a0f71d0d3f93950044389711960a0bcef0b75e31 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:16:03 -0500 Subject: [PATCH 19/99] Manage application matches as a list, not a comma-separated string 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. --- wavexlr/sourcedialog.py | 140 ++++++++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 20 deletions(-) diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index cda05ab..473a770 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -409,29 +409,46 @@ def _build_config_page(self, *, default_name=None, default_icon=None, name_group.add(self._name_row) # Application binding — app sources only. - self._app_row = None + # None on the capture-device page, a list on the application page. + self._bindings = None if show_app_row: app_group = Adw.PreferencesGroup( - title="Application", - description="Matched against PipeWire's application.name, its " - "node.name or its process binary, ignoring case " - "and spacing. Check the spelling with: " - "pw-dump | grep application.name", + title="Applications", + description="Audio from any of these is gathered under this " + "row's single fader.", ) outer.append(app_group) - self._app_row = Adw.EntryRow(title="Applications") - self._app_row.set_text(self._selected_app or "") - self._app_row.connect("changed", self._on_binding_changed) - app_group.add(self._app_row) - hint = Gtk.Label( - label="Separate several with commas, to gather them under one " - "fader \u2014 two music players, or every game.", - xalign=0, wrap=True, margin_top=6, + # A managed list rather than a comma-separated entry. The seeded + # rows carry a dozen names each, which is unreadable as one string + # and impossible to edit a single entry out of. + self._bindings = sources_module.parse_bindings(self._selected_app or "") + self._bind_group = app_group + self._bind_rows = [] + + self._add_row = Adw.EntryRow(title="Add an application") + add_btn = Gtk.Button( + icon_name="list-add-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Add this name", ) - hint.add_css_class("dim-label") - hint.add_css_class("caption") - app_group.add(hint) + add_btn.add_css_class("flat") + add_btn.connect("clicked", lambda _b: self._add_binding_from_entry()) + self._add_row.add_suffix(add_btn) + self._add_row.connect("entry-activated", + lambda _r: self._add_binding_from_entry()) + self._add_row.connect("changed", lambda _r: self._sync_confirm()) + + # Anything currently making sound, minus what is already bound -- + # the common case is "the app is running, I just do not know what + # PipeWire calls it". + self._running_btn = Gtk.MenuButton( + label="From running apps", halign=Gtk.Align.START, margin_top=6, + ) + self._running_btn.add_css_class("flat") + self._running_pop = Gtk.Popover() + self._running_btn.set_popover(self._running_pop) + + self._rebuild_bindings() elif editing: # A device source's binding is hardware, not text: show it, do not # offer to edit it. Re-pointing a row at a different capture device @@ -497,12 +514,90 @@ def _sync_confirm(self): """A source that binds nothing can never be metered or routed, so refuse to create one rather than persisting dead config. With no Application row (a capture device) the name is the only requirement.""" - if self._app_row is not None: - ok = bool(self._app_row.get_text().strip()) + if self._bindings is not None: + # A pending name in the entry counts: confirming without pressing + + # first should not silently discard what was typed. + pending = self._add_row.get_text().strip() if self._add_row else "" + ok = bool(self._bindings or pending) else: ok = bool(self._name_row.get_text().strip()) self._confirm_btn.set_sensitive(ok) + def _rebuild_bindings(self): + """Redraw one removable row per bound application.""" + for row in self._bind_rows: + self._bind_group.remove(row) + self._bind_rows = [] + + for name in self._bindings: + row = Adw.ActionRow(title=name) + row.add_prefix(Gtk.Image.new_from_icon_name("application-x-executable-symbolic")) + rm = Gtk.Button( + icon_name="window-close-symbolic", valign=Gtk.Align.CENTER, + tooltip_text=f"Stop matching {name}", + ) + rm.add_css_class("flat") + rm.connect("clicked", lambda _b, n=name: self._remove_binding(n)) + row.add_suffix(rm) + self._bind_group.add(row) + self._bind_rows.append(row) + + if not self._bindings: + empty = Adw.ActionRow( + title="No applications yet", + subtitle="Add one below, or pick from what is playing", + ) + empty.set_sensitive(False) + self._bind_group.add(empty) + self._bind_rows.append(empty) + + self._bind_group.add(self._add_row) + self._bind_rows.append(self._add_row) + self._bind_group.add(self._running_btn) + self._bind_rows.append(self._running_btn) + + self._populate_running_menu() + self._sync_confirm() + + def _populate_running_menu(self): + """List what is playing now, excluding names already bound.""" + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=2, + margin_top=6, margin_bottom=6, margin_start=6, margin_end=6, + ) + bound = {n.casefold() for n in self._bindings} + names = [] + for stream in list_audio_streams(): + for candidate in (stream.get("app_name"), stream.get("binary")): + if candidate and candidate.casefold() not in bound and candidate not in names: + names.append(candidate) + if not names: + lbl = Gtk.Label(label="Nothing is playing", margin_top=6, margin_bottom=6) + lbl.add_css_class("dim-label") + box.append(lbl) + for name in names: + btn = Gtk.Button(label=name, halign=Gtk.Align.FILL) + btn.add_css_class("flat") + btn.connect("clicked", lambda _b, n=name: self._add_binding(n)) + box.append(btn) + self._running_pop.set_child(box) + + def _add_binding_from_entry(self): + text = self._add_row.get_text().strip() + if text: + self._add_row.set_text("") + self._add_binding(text) + + def _add_binding(self, name): + if name.casefold() not in {n.casefold() for n in self._bindings}: + self._bindings.append(name) + self._running_pop.popdown() + self._rebuild_bindings() + + def _remove_binding(self, name): + self._bindings = [n for n in self._bindings if n != name] + self._rebuild_bindings() + def _on_icon_selected(self, flow): sel = flow.get_selected_children() if sel: @@ -511,7 +606,12 @@ def _on_icon_selected(self, flow): def _on_confirm(self, _btn): # Read the field, not _selected_app: with manual entry the picker's # value is "" and the entry is the only source of truth. - app = self._app_row.get_text().strip() if self._app_row is not None else "" + if self._bindings is not None: + # Fold in anything still sitting in the entry, unconfirmed. + self._add_binding_from_entry() + app = ", ".join(self._bindings) + else: + app = "" if self._source is None and not app: return name = self._name_row.get_text().strip() or app From 82f420f33e4b7d5f53ec67861d04a3f304439dc4 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:23:01 -0500 Subject: [PATCH 20/99] Reorder sources, make System the catch-all, and size the window to its 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. --- wavexlr/app.py | 75 ++++++++++++++++++++++++++++++++++++++++++-- wavexlr/mixer.py | 10 ++++++ wavexlr/mixmatrix.py | 73 ++++++++++++++++++++++++++++++++++++++++++ wavexlr/sources.py | 28 ++++++++++++++++- 4 files changed, 183 insertions(+), 3 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 2c4cba9..0a121de 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -5,6 +5,7 @@ gi.require_version('Adw', '1') from gi.repository import Gtk, Adw, GLib, GObject, Gio, Gdk +import json import logging import os import sys @@ -45,8 +46,17 @@ def _slider_row(scale): class WaveXLRWindow(Adw.ApplicationWindow): def __init__(self, **kwargs): - super().__init__(**kwargs, title="OpenWave", default_width=1100, default_height=620) - self.set_size_request(900, 520) + # The old 1100x620 could not show its own content: the matrix alone + # 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. Sized for three mixes and eight rows + # with headroom, then overridden by whatever size was last used. + super().__init__(**kwargs, title="OpenWave", + default_width=1360, default_height=800) + # Kept modest so the window still fits a small screen; the matrix + # scrolls rather than being clipped. + self.set_size_request(820, 480) + self._restore_window_size() self.dev = WaveDevice() self._gain_max = 0x5000 self._updating_ui = False @@ -88,6 +98,53 @@ def __init__(self, **kwargs): self._start_stream_poll() self._try_connect() + # Remembered across sessions: the right size depends on how many mixes and + # sources the user keeps, which only they know. + _WINDOW_STATE = os.path.expanduser("~/.config/openwave/window.json") + + def _restore_window_size(self): + try: + with open(self._WINDOW_STATE) as f: + state = json.load(f) + except (OSError, ValueError): + return + if not isinstance(state, dict): + return + width, height = state.get("width"), state.get("height") + if isinstance(width, int) and isinstance(height, int) \ + and width >= 820 and height >= 480: + self.set_default_size(width, height) + if state.get("maximized"): + self.maximize() + + def _save_window_size(self): + """Store the current size. Never fatal: a window that cannot record + its geometry should still close.""" + try: + os.makedirs(os.path.dirname(self._WINDOW_STATE), exist_ok=True) + state = { + "width": self.get_width(), + "height": self.get_height(), + "maximized": self.is_maximized(), + } + if state["maximized"]: + # get_width/height report the maximized size; keep the last + # restored size so unmaximizing does not snap to full screen. + previous = {} + try: + with open(self._WINDOW_STATE) as f: + previous = json.load(f) + except (OSError, ValueError): + pass + state["width"] = previous.get("width", state["width"]) + state["height"] = previous.get("height", state["height"]) + tmp = self._WINDOW_STATE + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, self._WINDOW_STATE) + except OSError: + pass + def _build_ui(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.set_content(box) @@ -186,6 +243,7 @@ def _build_ui(self): self.matrix.connect("add-source-clicked", self._on_add_source_clicked) self.matrix.connect("remove-source-clicked", self._on_remove_source_clicked) self.matrix.connect("edit-source-clicked", self._on_edit_source_clicked) + self.matrix.connect("move-source-clicked", self._on_move_source_clicked) self.matrix.connect("add-mix-clicked", self._on_add_mix_clicked) self.matrix.connect("rename-mix-clicked", self._on_rename_mix_clicked) self.matrix.connect("remove-mix-clicked", self._on_remove_mix_clicked) @@ -945,6 +1003,18 @@ def _install_source(self, source): self._refresh_source_meter(source["id"]) self._refresh_mix_emptiness() + def _on_move_source_clicked(self, _matrix, source_id, delta): + before = list(self._sources) + self._sources = sources_module.reorder(self._sources, source_id, delta) + if list(self._sources) == before: + return # already at that end of the list + # Every MixCell is rebuilt by the reorder, so the cells must be wired + # again: the old widgets are gone and the new ones carry no state. + self.matrix.reorder_sources(list(self._sources)) + self._wire_matrix_cells() + self._refresh_mix_emptiness() + self._start_meters() + def _on_edit_source_clicked(self, _matrix, source_id): source = self._sources.get(source_id) if source is None: @@ -1108,6 +1178,7 @@ def do_shutdown(self): """Stop polling, drop the USB link, and tear down loopback + meter subprocesses before the process exits.""" if self._window is not None: + self._window._save_window_size() self._window._stop_polling() if hasattr(self._window, "meter"): self._window.meter.stop_all() diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index ad93d43..fc38f10 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -428,6 +428,16 @@ def claim_streams(sources, streams): key = (rank, str(source_id)) if best_key is None or key < best_key: best_key, best_id = key, source_id + if best_id is None: + # Nothing named it. A catch-all source takes what no other source + # claimed, so an application whose reported name matches no row + # still lands somewhere with a fader instead of bypassing the + # matrix entirely. Only ever a fallback: an explicit name always + # wins, and a stream is still owned exactly once. + best_id = next( + (sid for sid, src in sources.items() if src.get("catch_all")), + None, + ) if best_id is not None: claims[best_id].add(stream_id) return claims diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 47be0e3..17547d5 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -19,6 +19,8 @@ class MixMatrix(Gtk.Box): "add-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "remove-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "edit-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + # (source_id, delta) -- -1 to move a row up, +1 to move it down + "move-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str, int)), "add-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "rename-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "remove-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), @@ -53,6 +55,8 @@ def __init__(self): self._mix_ids = [] self._source_ids = [] + # How each row was built, so reorder_sources can rebuild it verbatim. + self._source_specs = {} self._sources = {} self._headers = {} self._cells = {} @@ -180,9 +184,18 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, "remove-clicked", lambda _s, sid=source_id: self.emit("remove-source-clicked", sid), ) + source.connect( + "move-clicked", + lambda _s, delta, sid=source_id: self.emit("move-source-clicked", sid, delta), + ) self._grid.attach(source, 0, row, 1, 1) self._sources[source_id] = source self._source_ids.append(source_id) + # Remembered so reorder_sources can rebuild a row exactly as it was. + self._source_specs[source_id] = dict( + name=name, icon_name=icon_name, has_level=has_level, + removable=removable, editable=editable, + ) for col_idx, mix_id in enumerate(self._mix_ids): cell = MixCell() @@ -191,6 +204,36 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, return source + def reorder_sources(self, order): + """Redraw the source rows in `order`. + + Gtk.Grid has no row-move, so the rows are torn down and rebuilt. Every + MixCell is recreated, so the caller must re-wire the cells afterwards -- + their widgets are new objects and carry no state. + """ + specs = [(sid, self._source_specs[sid]) + for sid in order if sid in self._source_specs] + for _ in range(len(self._source_ids)): + self._grid.remove_row(1) # row 0 is the header; rows shift up + self._source_ids = [] + self._sources = {} + self._cells = {} + kept = dict(self._source_specs) + self._source_specs = {} + for sid, spec in specs: + self.add_source(sid, **spec) + self._source_specs.update({k: v for k, v in kept.items() + if k in self._source_specs}) + self.refresh_move_buttons() + + def refresh_move_buttons(self): + """Disable Up on the first row and Down on the last.""" + last = len(self._source_ids) - 1 + for idx, sid in enumerate(self._source_ids): + cell = self._sources.get(sid) + if cell is not None and hasattr(cell, "set_move_enabled"): + cell.set_move_enabled(idx > 0, idx < last) + def remove_source(self, source_id): if source_id not in self._source_ids: return @@ -198,6 +241,7 @@ def remove_source(self, source_id): self._grid.remove_row(idx + 1) self._source_ids.pop(idx) self._sources.pop(source_id, None) + self._source_specs.pop(source_id, None) for mix_id in self._mix_ids: self._cells.pop((source_id, mix_id), None) @@ -501,6 +545,8 @@ class SourceCell(Gtk.Box): "volume-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "mute-toggled": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "remove-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), + # (delta) -- -1 to move this row up, +1 to move it down + "move-clicked": (GObject.SignalFlags.RUN_FIRST, None, (int,)), "edit-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), } @@ -591,6 +637,24 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals inner.append(self._level) if editable: + up_btn = Gtk.Button( + icon_name="go-up-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Move up", + ) + up_btn.add_css_class("flat") + up_btn.add_css_class("circular") + up_btn.connect("clicked", lambda _b: self.emit("move-clicked", -1)) + self._up_btn = up_btn + + down_btn = Gtk.Button( + icon_name="go-down-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Move down", + ) + down_btn.add_css_class("flat") + down_btn.add_css_class("circular") + down_btn.connect("clicked", lambda _b: self.emit("move-clicked", 1)) + self._down_btn = down_btn + edit_btn = Gtk.Button( icon_name="document-edit-symbolic", valign=Gtk.Align.CENTER, @@ -599,6 +663,8 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals edit_btn.add_css_class("flat") edit_btn.add_css_class("circular") edit_btn.connect("clicked", lambda _: self.emit("edit-clicked")) + inner.append(up_btn) + inner.append(down_btn) inner.append(edit_btn) if removable: @@ -612,6 +678,13 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals remove_btn.connect("clicked", lambda _: self.emit("remove-clicked")) inner.append(remove_btn) + def set_move_enabled(self, up, down): + """Grey the ends of the list rather than letting them do nothing.""" + if getattr(self, "_up_btn", None) is not None: + self._up_btn.set_sensitive(up) + if getattr(self, "_down_btn", None) is not None: + self._down_btn.set_sensitive(down) + def set_name(self, name): self._name_lbl.set_label(name) diff --git a/wavexlr/sources.py b/wavexlr/sources.py index b29c064..22fc912 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -86,7 +86,13 @@ def kind(source): # as "Discord"; a Proton game reports its own name under a wine binary). DEFAULT_SOURCES = { "system": { - "id": "system", "name": "System", "icon_name": "preferences-system-symbolic", + "id": "system", "name": "System", + "subtitle": "Anything not matched by another row", + "icon_name": "preferences-system-symbolic", + # Takes anything no other row claims, so a program nobody has named + # still gets a fader rather than slipping past the matrix. Listed names + # still win, so this only ever catches the remainder. + "catch_all": True, "match_app_names": [ "gnome-shell", "GNOME Shell", "gsd-media-keys", "plasmashell", "libcanberra", "canberra-gtk-play", "speech-dispatcher", @@ -196,6 +202,26 @@ def remove(sources, source_id): save(sources) return sources +def reorder(sources, source_id, delta): + """Move a source `delta` places in the list, and persist the new order. + + Insertion order is row order, so reordering means rebuilding the mapping. + Out-of-range moves are clamped rather than wrapping: a button at the end of + the list should do nothing, not jump the row to the other end. + """ + order = list(sources) + if source_id not in order: + return sources + idx = order.index(source_id) + new_idx = max(0, min(len(order) - 1, idx + delta)) + if new_idx == idx: + return sources + order.insert(new_idx, order.pop(idx)) + reordered = {sid: sources[sid] for sid in order} + save(reordered) + return reordered + + def update(sources, source_id, **fields): """Edit a source in place, preserving its id. From 42c4a086efa5abfbb3dab6c57eb2edc865b2d8a5 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:34:58 -0500 Subject: [PATCH 21/99] Drag to reorder, wire the source sliders, and fix the row layout 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. --- wavexlr/app.py | 103 ++++++++++++++++++++++------ wavexlr/mixer.py | 33 ++++++++- wavexlr/mixmatrix.py | 156 ++++++++++++++++++++++++++++++------------- wavexlr/style.css | 4 ++ 4 files changed, 229 insertions(+), 67 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 0a121de..c3c494b 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -76,6 +76,7 @@ def __init__(self, **kwargs): self._mixes = mixes_module.load_seeded() self._build_ui() + self._restore_gain_lock() self._update_service_status() self.mixer = Mixer() self.mixer.set_mixes(self._mixes) @@ -100,16 +101,33 @@ def __init__(self, **kwargs): # Remembered across sessions: the right size depends on how many mixes and # sources the user keeps, which only they know. - _WINDOW_STATE = os.path.expanduser("~/.config/openwave/window.json") + _UI_STATE = os.path.expanduser("~/.config/openwave/ui-state.json") - def _restore_window_size(self): + def _on_gain_lock_toggled(self, btn): + locked = btn.get_active() + self.gain_scale.set_sensitive(not locked) + btn.set_icon_name( + "changes-prevent-symbolic" if locked else "changes-allow-symbolic" + ) + btn.set_tooltip_text("Gain locked \u2014 click to unlock" if locked + else "Lock gain") + self._save_ui_state() + + def _restore_gain_lock(self): + state = self._load_ui_state() + if state.get("gain_locked"): + self.gain_lock.set_active(True) # toggled fires and applies it + + def _load_ui_state(self): try: - with open(self._WINDOW_STATE) as f: + with open(self._UI_STATE) as f: state = json.load(f) except (OSError, ValueError): - return - if not isinstance(state, dict): - return + return {} + return state if isinstance(state, dict) else {} + + def _restore_window_size(self): + state = self._load_ui_state() width, height = state.get("width"), state.get("height") if isinstance(width, int) and isinstance(height, int) \ and width >= 820 and height >= 480: @@ -117,31 +135,32 @@ def _restore_window_size(self): if state.get("maximized"): self.maximize() - def _save_window_size(self): - """Store the current size. Never fatal: a window that cannot record - its geometry should still close.""" + def _save_ui_state(self): + """Store window geometry and the gain lock. + + Never fatal: a window that cannot record its state should still close, + and a lock toggle that cannot persist should still take effect now. + """ try: - os.makedirs(os.path.dirname(self._WINDOW_STATE), exist_ok=True) + os.makedirs(os.path.dirname(self._UI_STATE), exist_ok=True) state = { "width": self.get_width(), "height": self.get_height(), "maximized": self.is_maximized(), + "gain_locked": bool( + getattr(self, "gain_lock", None) and self.gain_lock.get_active() + ), } if state["maximized"]: # get_width/height report the maximized size; keep the last # restored size so unmaximizing does not snap to full screen. - previous = {} - try: - with open(self._WINDOW_STATE) as f: - previous = json.load(f) - except (OSError, ValueError): - pass + previous = self._load_ui_state() state["width"] = previous.get("width", state["width"]) state["height"] = previous.get("height", state["height"]) - tmp = self._WINDOW_STATE + ".tmp" + tmp = self._UI_STATE + ".tmp" with open(tmp, "w") as f: json.dump(state, f, indent=2) - os.replace(tmp, self._WINDOW_STATE) + os.replace(tmp, self._UI_STATE) except OSError: pass @@ -184,7 +203,9 @@ def _build_ui(self): self.sidebar_toggle = Gtk.ToggleButton( icon_name="sidebar-show-symbolic", tooltip_text="Toggle device panel", - active=True, + # Closed by default: the matrix is the thing you came for, and the + # device controls are set once and then left alone. + active=False, ) header.pack_end(self.sidebar_toggle) box.append(header) @@ -238,7 +259,9 @@ def _build_ui(self): has_level=True, removable=True, editable=True, + reorderable=True, ) + self._wire_source_row(source_id) self.matrix.connect("add-source-clicked", self._on_add_source_clicked) self.matrix.connect("remove-source-clicked", self._on_remove_source_clicked) @@ -282,6 +305,17 @@ def _build_device_pane(self, parent): self.gain_label = Gtk.Label(label="—", width_chars=8, xalign=1) self.gain_label.add_css_class("monospace") gain_row.add_suffix(self.gain_label) + + # Preamp gain is set once and then wants leaving alone: a stray scroll + # over the slider silently changes how loud you are to everyone else, + # and nothing on screen makes that obvious afterwards. + self.gain_lock = Gtk.ToggleButton( + icon_name="changes-allow-symbolic", valign=Gtk.Align.CENTER, + tooltip_text="Lock gain", + ) + self.gain_lock.add_css_class("flat") + self.gain_lock.connect("toggled", self._on_gain_lock_toggled) + gain_row.add_suffix(self.gain_lock) mic_group.add(gain_row) self.gain_scale = Gtk.Scale( @@ -995,7 +1029,9 @@ def _install_source(self, source): has_level=True, removable=True, editable=True, + reorderable=True, ) + self._wire_source_row(source["id"]) for mix_id in self._mixes: self._wire_cell(source["id"], mix_id) self.mixer.set_sources(self._sources) @@ -1003,6 +1039,31 @@ def _install_source(self, source): self._refresh_source_meter(source["id"]) self._refresh_mix_emptiness() + def _wire_source_row(self, source_id): + """Connect a source row's own level slider and mute. + + Distinct from the mix cells beside it: this is the source's level + everywhere, applied to its intake sink ahead of the per-mix faders. + """ + cell = self.matrix.source(source_id) + if cell is None: + return + source = self._sources.get(source_id, {}) + cell.set_volume(float(source.get("level", 1.0))) + cell.set_muted(bool(source.get("muted", False))) + cell.connect("volume-changed", self._on_source_level_changed, source_id) + cell.connect("mute-toggled", self._on_source_mute_toggled, source_id) + + def _on_source_level_changed(self, _cell, volume, source_id): + self.mixer.set_source_level( + source_id, volume, self._sources.get(source_id, {}).get("muted", False)) + sources_module.save(self._sources) + + def _on_source_mute_toggled(self, _cell, muted, source_id): + self.mixer.set_source_level( + source_id, self._sources.get(source_id, {}).get("level", 1.0), muted) + sources_module.save(self._sources) + def _on_move_source_clicked(self, _matrix, source_id, delta): before = list(self._sources) self._sources = sources_module.reorder(self._sources, source_id, delta) @@ -1011,6 +1072,8 @@ def _on_move_source_clicked(self, _matrix, source_id, delta): # Every MixCell is rebuilt by the reorder, so the cells must be wired # again: the old widgets are gone and the new ones carry no state. self.matrix.reorder_sources(list(self._sources)) + for sid in self._sources: + self._wire_source_row(sid) self._wire_matrix_cells() self._refresh_mix_emptiness() self._start_meters() @@ -1178,7 +1241,7 @@ def do_shutdown(self): """Stop polling, drop the USB link, and tear down loopback + meter subprocesses before the process exits.""" if self._window is not None: - self._window._save_window_size() + self._window._save_ui_state() self._window._stop_polling() if hasattr(self._window, "meter"): self._window.meter.stop_all() diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index fc38f10..67b7bac 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1194,6 +1194,33 @@ def _source_is_routed(self, source_id): for mix_id in mix_ids ) + def set_source_level(self, source_id, volume, muted): + """A source's overall level: the volume of its intake sink. + + Applies to that source in every mix at once, ahead of the per-mix + faders -- a channel trim rather than a send. Persisted on the source + record so it is restored deterministically rather than depending on + WirePlumber having remembered the sink. + """ + with self._lock: + source = self._sources.get(source_id) + if source is not None: + source["level"] = max(0.0, min(1.0, float(volume))) + source["muted"] = bool(muted) + self._enqueue( + ("srclevel", source_id), + lambda sid=source_id: self._do_apply_source_level(sid), + ) + + def _do_apply_source_level(self, source_id): + with self._lock: + source = dict(self._sources.get(source_id) or {}) + node_id = _node_id_by_name(source_sink_name(source_id)) + if node_id is None: + return # not routed, so no intake to set + _wpctl("set-volume", node_id, f"{float(source.get('level', 1.0)):.3f}") + _wpctl("set-mute", node_id, "1" if source.get("muted") else "0") + def _ensure_source_sink(self, source_id, description): """Create the source's intake sink if it is not already live.""" from . import setup @@ -1202,7 +1229,11 @@ def _ensure_source_sink(self, source_id, description): setup.create_null_sink(name, f"OpenWave: {description}") except Exception: return None - self._intakes.add(source_id) + if source_id not in self._intakes: + self._intakes.add(source_id) + # A freshly created sink is at unity and unmuted; push the stored + # level onto it so the slider means something immediately. + self._do_apply_source_level(source_id) return name def _destroy_source_sink(self, source_id): diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 17547d5..2b2617f 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -9,7 +9,20 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw, GObject, Pango # noqa: E402 +from gi.repository import Gtk, Adw, GObject, Gdk, Pango # noqa: E402 + + +def _percent_label(): + """A fixed-width percentage readout for a 0..1 slider. + + Monospace and width-limited so the row does not reflow as the number + changes width between 0% and 100%. + """ + lbl = Gtk.Label(label="0%", xalign=1, width_chars=4) + lbl.add_css_class("dim-label") + lbl.add_css_class("caption") + lbl.add_css_class("monospace") + return lbl class MixMatrix(Gtk.Box): @@ -62,7 +75,10 @@ def __init__(self): self._cells = {} corner = Gtk.Box() - corner.set_size_request(260, 64) + # Wide enough for the row's full contents: drag handle, icon, name, + # mute, level, meter, edit and remove. At 260 the name was the only + # flexible part, so it ellipsized away to nothing. + corner.set_size_request(400, 64) self._grid.attach(corner, 0, 0, 1, 1) # "+ Add Source" / "+ Add Mix" trailing affordances, below the grid. @@ -79,7 +95,7 @@ def __init__(self): halign=Gtk.Align.START, ) self._add_btn.add_css_class("openwave-add-source") - self._add_btn.set_size_request(260, -1) + self._add_btn.set_size_request(400, -1) self._add_btn.connect("clicked", lambda _: self.emit("add-source-clicked")) add_row.append(self._add_btn) @@ -168,11 +184,11 @@ def set_mix_outputs(self, mix_id, entries, current, summary, monitored=True): header.set_outputs(entries, current, summary, monitored) def add_source(self, source_id, *, name, icon_name, has_level=False, - removable=False, editable=False): + removable=False, editable=False, reorderable=False): row = len(self._source_ids) + 1 source = SourceCell( - name=name, icon_name=icon_name, - has_level=has_level, removable=removable, editable=editable, + name=name, icon_name=icon_name, has_level=has_level, + removable=removable, editable=editable, reorderable=reorderable, ) if editable: source.connect( @@ -184,6 +200,8 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, "remove-clicked", lambda _s, sid=source_id: self.emit("remove-source-clicked", sid), ) + if reorderable: + self._make_row_draggable(source, source_id) source.connect( "move-clicked", lambda _s, delta, sid=source_id: self.emit("move-source-clicked", sid, delta), @@ -194,7 +212,7 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, # Remembered so reorder_sources can rebuild a row exactly as it was. self._source_specs[source_id] = dict( name=name, icon_name=icon_name, has_level=has_level, - removable=removable, editable=editable, + removable=removable, editable=editable, reorderable=reorderable, ) for col_idx, mix_id in enumerate(self._mix_ids): @@ -204,6 +222,52 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, return source + def _make_row_draggable(self, cell, source_id): + """Let a row be dragged onto another to take its place. + + The drop is expressed as a delta and pushed through the same + move-source-clicked path the buttons used, so ordering, clamping and + persistence stay in one place. + """ + drag = Gtk.DragSource(actions=Gdk.DragAction.MOVE) + drag.connect( + "prepare", + lambda _d, _x, _y, sid=source_id: Gdk.ContentProvider.new_for_value(sid), + ) + + def _begin(_source, drag_obj, widget=cell): + # Drag the row's own likeness, so it is obvious what is moving. + icon = Gtk.DragIcon.get_for_drag(drag_obj) + paintable = Gtk.WidgetPaintable.new(widget) + picture = Gtk.Picture.new_for_paintable(paintable) + picture.set_size_request(widget.get_width(), widget.get_height()) + icon.set_child(picture) + widget.set_opacity(0.35) + + drag.connect("drag-begin", _begin) + drag.connect("drag-end", lambda _s, _d, _r, w=cell: w.set_opacity(1.0)) + drag.connect("drag-cancel", + lambda _s, _d, _r, w=cell: (w.set_opacity(1.0), False)[1]) + cell.add_controller(drag) + + drop = Gtk.DropTarget.new(GObject.TYPE_STRING, Gdk.DragAction.MOVE) + drop.connect("drop", self._on_row_drop, source_id) + drop.connect("enter", lambda _t, _x, _y, w=cell: + (w.add_css_class("openwave-drop-target"), Gdk.DragAction.MOVE)[1]) + drop.connect("leave", lambda _t, w=cell: w.remove_css_class("openwave-drop-target")) + cell.add_controller(drop) + + def _on_row_drop(self, _target, value, _x, _y, target_id): + dragged = str(value) + cell = self._sources.get(target_id) + if cell is not None: + cell.remove_css_class("openwave-drop-target") + if dragged == target_id or dragged not in self._source_ids: + return False + delta = self._source_ids.index(target_id) - self._source_ids.index(dragged) + self.emit("move-source-clicked", dragged, delta) + return True + def reorder_sources(self, order): """Redraw the source rows in `order`. @@ -211,8 +275,13 @@ def reorder_sources(self, order): MixCell is recreated, so the caller must re-wire the cells afterwards -- their widgets are new objects and carry no state. """ + # The built-in microphone row is pinned to the top and is not part of + # the user's ordering: it is not in the sources store, so `order` never + # mentions it, and rebuilding without it would delete the row outright. + pinned = [sid for sid in self._source_ids if sid not in order] specs = [(sid, self._source_specs[sid]) - for sid in order if sid in self._source_specs] + for sid in pinned + [s for s in order if s not in pinned] + if sid in self._source_specs] for _ in range(len(self._source_ids)): self._grid.remove_row(1) # row 0 is the header; rows shift up self._source_ids = [] @@ -224,15 +293,6 @@ def reorder_sources(self, order): self.add_source(sid, **spec) self._source_specs.update({k: v for k, v in kept.items() if k in self._source_specs}) - self.refresh_move_buttons() - - def refresh_move_buttons(self): - """Disable Up on the first row and Down on the last.""" - last = len(self._source_ids) - 1 - for idx, sid in enumerate(self._source_ids): - cell = self._sources.get(sid) - if cell is not None and hasattr(cell, "set_move_enabled"): - cell.set_move_enabled(idx > 0, idx < last) def remove_source(self, source_id): if source_id not in self._source_ids: @@ -550,14 +610,15 @@ class SourceCell(Gtk.Box): "edit-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), } - def __init__(self, *, name, icon_name, has_level, removable=False, editable=False): + def __init__(self, *, name, icon_name, has_level, removable=False, + editable=False, reorderable=False): super().__init__( orientation=Gtk.Orientation.HORIZONTAL, spacing=10, ) self.add_css_class("openwave-source-cell") self.add_css_class("card") - self.set_size_request(260, 64) + self.set_size_request(400, 64) inner = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, @@ -570,6 +631,13 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals ) self.append(inner) + if reorderable: + handle = Gtk.Image.new_from_icon_name("list-drag-handle-symbolic") + handle.set_pixel_size(14) + handle.add_css_class("dim-label") + handle.set_tooltip_text("Drag to reorder") + inner.append(handle) + self._icon = Gtk.Image.new_from_icon_name(icon_name) self._icon.set_pixel_size(26) inner.append(self._icon) @@ -583,6 +651,10 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals inner.append(text) self._name_lbl = Gtk.Label(label=name, xalign=0, hexpand=True, ellipsize=3) + # Without a width request the label yields all its space to the + # controls beside it and renders as a bare ellipsis. + self._name_lbl.set_width_chars(10) + self._name_lbl.set_tooltip_text(name) self._name_lbl.add_css_class("heading") text.append(self._name_lbl) @@ -614,10 +686,12 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals valign=Gtk.Align.CENTER, round_digits=2, ) + self._pct_lbl = _percent_label() self._scale.add_css_class("openwave-mix-slider") self._scale.set_size_request(110, -1) self._scale_handler = self._scale.connect("value-changed", self._on_value_changed) inner.append(self._scale) + inner.append(self._pct_lbl) self._level = None if has_level: @@ -637,24 +711,6 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals inner.append(self._level) if editable: - up_btn = Gtk.Button( - icon_name="go-up-symbolic", valign=Gtk.Align.CENTER, - tooltip_text="Move up", - ) - up_btn.add_css_class("flat") - up_btn.add_css_class("circular") - up_btn.connect("clicked", lambda _b: self.emit("move-clicked", -1)) - self._up_btn = up_btn - - down_btn = Gtk.Button( - icon_name="go-down-symbolic", valign=Gtk.Align.CENTER, - tooltip_text="Move down", - ) - down_btn.add_css_class("flat") - down_btn.add_css_class("circular") - down_btn.connect("clicked", lambda _b: self.emit("move-clicked", 1)) - self._down_btn = down_btn - edit_btn = Gtk.Button( icon_name="document-edit-symbolic", valign=Gtk.Align.CENTER, @@ -663,8 +719,6 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals edit_btn.add_css_class("flat") edit_btn.add_css_class("circular") edit_btn.connect("clicked", lambda _: self.emit("edit-clicked")) - inner.append(up_btn) - inner.append(down_btn) inner.append(edit_btn) if removable: @@ -678,15 +732,9 @@ def __init__(self, *, name, icon_name, has_level, removable=False, editable=Fals remove_btn.connect("clicked", lambda _: self.emit("remove-clicked")) inner.append(remove_btn) - def set_move_enabled(self, up, down): - """Grey the ends of the list rather than letting them do nothing.""" - if getattr(self, "_up_btn", None) is not None: - self._up_btn.set_sensitive(up) - if getattr(self, "_down_btn", None) is not None: - self._down_btn.set_sensitive(down) - def set_name(self, name): self._name_lbl.set_label(name) + self._name_lbl.set_tooltip_text(name) def set_icon(self, icon_name): self._icon.set_from_icon_name(icon_name) @@ -704,10 +752,16 @@ def set_available(self, available, *, reason="Device not connected"): self._name_lbl.add_css_class("dim-label") self.set_tooltip_text(reason) + def _sync_percent(self): + if getattr(self, "_pct_lbl", None) is not None: + self._pct_lbl.set_label(f"{round(self._scale.get_value() * 100):d}%") + def set_volume(self, value): """Update the master slider without firing the changed signal.""" with GObject.signal_handler_block(self._scale, self._scale_handler): self._scale.set_value(max(0.0, min(1.0, value))) + # The changed handler is blocked above, so the readout is updated here. + self._sync_percent() def set_level(self, value): """Update the audio activity meter (0.0–1.0). No-op if not enabled.""" @@ -755,6 +809,7 @@ def _reflect_mute_icon(self, muted): self._level.add_css_class("success") def _on_value_changed(self, scale): + self._sync_percent() self.emit("volume-changed", scale.get_value()) def _on_mute_toggled(self, btn): @@ -809,13 +864,21 @@ def __init__(self): hexpand=True, round_digits=2, ) + self._pct_lbl = _percent_label() self._scale.add_css_class("openwave-mix-slider") self._scale_handler = self._scale.connect("value-changed", self._on_value_changed) inner.append(self._scale) + inner.append(self._pct_lbl) + + def _sync_percent(self): + if getattr(self, "_pct_lbl", None) is not None: + self._pct_lbl.set_label(f"{round(self._scale.get_value() * 100):d}%") def set_volume(self, value): with GObject.signal_handler_block(self._scale, self._scale_handler): self._scale.set_value(max(0.0, min(1.0, value))) + # The changed handler is blocked above, so the readout is updated here. + self._sync_percent() def set_muted(self, muted): with GObject.signal_handler_block(self._mute_btn, self._mute_handler): @@ -825,6 +888,7 @@ def set_muted(self, muted): ) def _on_value_changed(self, scale): + self._sync_percent() self.emit("volume-changed", scale.get_value()) def _on_mute_toggled(self, btn): diff --git a/wavexlr/style.css b/wavexlr/style.css index 59b17ba..57e1cf8 100644 --- a/wavexlr/style.css +++ b/wavexlr/style.css @@ -38,3 +38,7 @@ .openwave-source-waiting { opacity: 0.55; } + +.openwave-drop-target { + box-shadow: inset 0 3px 0 0 @accent_bg_color; +} From 140a5cdeed219d667e81af96b69b4c37b0a4c97a Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:40:27 -0500 Subject: [PATCH 22/99] Make the source row slider actually attenuate 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. --- wavexlr/mixer.py | 38 ++++++++++++++++++++++++++++++-------- wavexlr/setup.py | 6 ++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 67b7bac..810ccd9 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1121,7 +1121,10 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted self._spawn_loopback(key, capture_node, mix_sink, node_name) node_id = _node_id_by_name(node_name) if node_id is not None: - _wpctl("set-volume", node_id, f"{volume:.3f}") + # cell fader x source trim: the row slider scales this source + # everywhere, the cell decides how much of it this mix gets. + _wpctl("set-volume", node_id, + f"{volume * self._source_gain(source_id):.3f}") _wpctl("set-mute", node_id, "1" if muted else "0") def _reconcile_app_cell(self, source_id, mix_id, volume, muted): @@ -1177,7 +1180,10 @@ def _reconcile_app_cell(self, source_id, mix_id, volume, muted): self._spawn_loopback(key, intake, mix_sink, node_name) node_id = _node_id_by_name(node_name) if node_id is not None: - _wpctl("set-volume", node_id, f"{volume:.3f}") + # cell fader x source trim: the row slider scales this source + # everywhere, the cell decides how much of it this mix gets. + _wpctl("set-volume", node_id, + f"{volume * self._source_gain(source_id):.3f}") _wpctl("set-mute", node_id, "1" if muted else "0") def _source_is_routed(self, source_id): @@ -1213,13 +1219,29 @@ def set_source_level(self, source_id, volume, muted): ) def _do_apply_source_level(self, source_id): + """Re-apply every cell for this source, so the trim takes effect. + + Deliberately NOT the intake sink's own volume. A null sink's monitor + does not follow it: measured, setting the sink to zero left the monitor + at full scale, because the pulse layer's flat-volume handling raises the + stream to compensate. The per-mix loopback volume is the one control + that demonstrably attenuates, so the trim multiplies into that. + """ with self._lock: - source = dict(self._sources.get(source_id) or {}) - node_id = _node_id_by_name(source_sink_name(source_id)) - if node_id is None: - return # not routed, so no intake to set - _wpctl("set-volume", node_id, f"{float(source.get('level', 1.0)):.3f}") - _wpctl("set-mute", node_id, "1" if source.get("muted") else "0") + mix_ids = list(self._mixes) + for mix_id in mix_ids: + self._reconcile_cell(source_id, mix_id) + + def _source_gain(self, source_id): + """A source's trim: its level, or 0 while it is muted.""" + with self._lock: + source = self._sources.get(source_id) or {} + if source.get("muted"): + return 0.0 + try: + return max(0.0, min(1.0, float(source.get("level", 1.0)))) + except (TypeError, ValueError): + return 1.0 def _ensure_source_sink(self, source_id, description): """Create the source's intake sink if it is not already live.""" diff --git a/wavexlr/setup.py b/wavexlr/setup.py index a060ad4..c530915 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -244,6 +244,12 @@ def _create_mix_sink_live(name, description): "media.class=Audio/Sink " "audio.position=[FL FR] " "object.linger=true " + # Without this a null sink's monitor is taken PRE-volume, so setting + # the sink's volume changes nothing downstream -- and at volume 0 the + # monitor was measured at full scale rather than silence. The generated + # config sets it on every mix sink; this path creates the same kind of + # node and must match, or a source's level slider does nothing. + "monitor.channel-volumes=true " "}" ) try: From f0857feafae8b6911596172c6c7c2f6e60417f66 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:45:25 -0500 Subject: [PATCH 23/99] Microphone icons on capture rows, and mark a muted row 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. --- wavexlr/app.py | 3 +++ wavexlr/mixmatrix.py | 28 +++++++++++++++++++++++----- wavexlr/style.css | 14 ++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index c3c494b..ccaaa74 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -246,6 +246,7 @@ def _build_ui(self): "mic", name="Microphone", icon_name="audio-input-microphone-symbolic", has_level=True, + is_capture=True, ) self.mic_source.connect("volume-changed", self._on_mic_matrix_volume_changed) self.mic_source.connect("mute-toggled", self._on_mic_matrix_mute_toggled) @@ -260,6 +261,7 @@ def _build_ui(self): removable=True, editable=True, reorderable=True, + is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, ) self._wire_source_row(source_id) @@ -1030,6 +1032,7 @@ def _install_source(self, source): removable=True, editable=True, reorderable=True, + is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, ) self._wire_source_row(source["id"]) for mix_id in self._mixes: diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 2b2617f..09d3eae 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -184,11 +184,13 @@ def set_mix_outputs(self, mix_id, entries, current, summary, monitored=True): header.set_outputs(entries, current, summary, monitored) def add_source(self, source_id, *, name, icon_name, has_level=False, - removable=False, editable=False, reorderable=False): + removable=False, editable=False, reorderable=False, + is_capture=False): row = len(self._source_ids) + 1 source = SourceCell( name=name, icon_name=icon_name, has_level=has_level, removable=removable, editable=editable, reorderable=reorderable, + is_capture=is_capture, ) if editable: source.connect( @@ -213,6 +215,7 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, self._source_specs[source_id] = dict( name=name, icon_name=icon_name, has_level=has_level, removable=removable, editable=editable, reorderable=reorderable, + is_capture=is_capture, ) for col_idx, mix_id in enumerate(self._mix_ids): @@ -611,7 +614,7 @@ class SourceCell(Gtk.Box): } def __init__(self, *, name, icon_name, has_level, removable=False, - editable=False, reorderable=False): + editable=False, reorderable=False, is_capture=False): super().__init__( orientation=Gtk.Orientation.HORIZONTAL, spacing=10, @@ -619,6 +622,9 @@ def __init__(self, *, name, icon_name, has_level, removable=False, self.add_css_class("openwave-source-cell") self.add_css_class("card") self.set_size_request(400, 64) + # A microphone row is muted at the microphone, not at a speaker: the + # playback icons there read as "this output is silenced". + self._is_capture = is_capture inner = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, @@ -797,9 +803,21 @@ def set_muted(self, muted): self._reflect_mute_icon(muted) def _reflect_mute_icon(self, muted): - self._mute_icon.set_from_icon_name( - "audio-volume-muted-symbolic" if muted else "audio-volume-high-symbolic" - ) + if getattr(self, "_is_capture", False): + icon = ("microphone-sensitivity-muted-symbolic" if muted + else "audio-input-microphone-symbolic") + else: + icon = ("audio-volume-muted-symbolic" if muted + else "audio-volume-high-symbolic") + self._mute_icon.set_from_icon_name(icon) + self._mute_btn.set_tooltip_text("Unmute" if muted else "Mute") + # A muted row should be obvious at a glance down the column, not a + # difference of one small icon. + for widget in (self, self._name_lbl, self._mute_icon): + if muted: + widget.add_css_class("openwave-muted") + else: + widget.remove_css_class("openwave-muted") if self._level is not None: if muted: self._level.add_css_class("dim-label") diff --git a/wavexlr/style.css b/wavexlr/style.css index 57e1cf8..251aea2 100644 --- a/wavexlr/style.css +++ b/wavexlr/style.css @@ -42,3 +42,17 @@ .openwave-drop-target { box-shadow: inset 0 3px 0 0 @accent_bg_color; } + +/* A muted source row: unmistakable scanning down the column, without + hiding the controls that un-mute it. */ +.openwave-source-cell.openwave-muted { + background-color: alpha(@error_color, 0.12); +} +.openwave-muted label, +label.openwave-muted { + color: @error_color; +} +.openwave-muted image, +image.openwave-muted { + color: @error_color; +} From b4c1ee0457090535434a012dc52e22495ae9d16a Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 17:48:05 -0500 Subject: [PATCH 24/99] Mark muted mix cells, start them muted, and keep 0% and mute in step 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. --- wavexlr/mixmatrix.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 09d3eae..22443bc 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -884,6 +884,12 @@ def __init__(self): ) self._pct_lbl = _percent_label() self._scale.add_css_class("openwave-mix-slider") + + # A new cell routes nothing, so it starts muted and says so. Leaving it + # unmuted at 0% shows an armed-looking control that carries no audio. + with GObject.signal_handler_block(self._mute_btn, self._mute_handler): + self._mute_btn.set_active(True) + self._reflect_mute(True) self._scale_handler = self._scale.connect("value-changed", self._on_value_changed) inner.append(self._scale) inner.append(self._pct_lbl) @@ -898,20 +904,36 @@ def set_volume(self, value): # The changed handler is blocked above, so the readout is updated here. self._sync_percent() - def set_muted(self, muted): - with GObject.signal_handler_block(self._mute_btn, self._mute_handler): - self._mute_btn.set_active(muted) + def _reflect_mute(self, muted): self._mute_icon.set_from_icon_name( "audio-volume-muted-symbolic" if muted else "audio-volume-high-symbolic" ) + self._mute_btn.set_tooltip_text("Unmute" if muted else "Mute") + for widget in (self, self._mute_icon): + if muted: + widget.add_css_class("openwave-muted") + else: + widget.remove_css_class("openwave-muted") + + def set_muted(self, muted): + with GObject.signal_handler_block(self._mute_btn, self._mute_handler): + self._mute_btn.set_active(muted) + self._reflect_mute(muted) def _on_value_changed(self, scale): self._sync_percent() + # A cell at zero and a muted cell mean the same thing, and letting them + # disagree produces a slider at 0% next to an unmuted icon, or a slider + # the user raises with no sound because a mute they forgot is still on. + should_mute = scale.get_value() <= 0.0 + if should_mute != self._mute_btn.get_active(): + with GObject.signal_handler_block(self._mute_btn, self._mute_handler): + self._mute_btn.set_active(should_mute) + self._reflect_mute(should_mute) + self.emit("mute-toggled", should_mute) self.emit("volume-changed", scale.get_value()) def _on_mute_toggled(self, btn): muted = btn.get_active() - self._mute_icon.set_from_icon_name( - "audio-volume-muted-symbolic" if muted else "audio-volume-high-symbolic" - ) + self._reflect_mute(muted) self.emit("mute-toggled", muted) From 81ca6a51897b83bcb971ddd35ad7499c18f31f3c Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:01:13 -0500 Subject: [PATCH 25/99] Add a unit test suite, CI, and document the routing model 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. --- .github/workflows/tests.yml | 41 +++++++++ README.md | 47 +++++++++- docs/ARCHITECTURE.md | 144 +++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/support.py | 60 +++++++++++++ tests/test_config_render.py | 86 +++++++++++++++++++ tests/test_device_scaling.py | 77 +++++++++++++++++ tests/test_matching.py | 105 +++++++++++++++++++++++ tests/test_mixer_state.py | 161 +++++++++++++++++++++++++++++++++++ tests/test_stores.py | 157 ++++++++++++++++++++++++++++++++++ 10 files changed, 877 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 tests/__init__.py create mode 100644 tests/support.py create mode 100644 tests/test_config_render.py create mode 100644 tests/test_device_scaling.py create mode 100644 tests/test_matching.py create mode 100644 tests/test_mixer_state.py create mode 100644 tests/test_stores.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..cea0684 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +jobs: + unit: + name: Unit tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 3.10 is the floor the README states; the newest catches deprecations + # early. Nothing between them is interesting enough to pay for. + python: ["3.10", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + # wavexlr/device.py loads libusb through ctypes at import time, so the + # runner needs the shared library even though no device is present. It + # is the only system dependency the suite has. + - name: Install libusb + run: sudo apt-get update -qq && sudo apt-get install -y -qq libusb-1.0-0 + + # No Python dependencies on purpose. The suite covers the backend modules, + # which import neither GTK nor libusb, so it runs on a bare runner with + # no audio server, no PipeWire and no hardware. Anything needing those + # belongs in a manual check, not here. + - name: Run unit tests + run: python -m unittest discover -s tests -t . -v + + - name: Byte-compile every module + run: python -m compileall -q wavexlr tests diff --git a/README.md b/README.md index be1f40f..f7f5162 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,21 @@ Linux control application for **Elgato Wave** audio devices — the **Wave XLR** | Device | USB ID | Controls | |---|---|---| | Wave XLR | `0fd9:007d` | Gain, mute, headphone volume, low impedance mode | +| Wave XLR MK.2 | `0fd9:00a6` | as the Wave XLR — it enumerates as "Elgato XLR Dock" and speaks the same vendor protocol | | Wave:3 | `0fd9:0070` | Gain, mute, headphone volume, monitor mix | ## Features +- **Mixing matrix** — user-defined mixes as columns, sources as rows. Each cell + is how much of that source the mix receives; each source row carries a trim + applying everywhere. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +- **Sources** — an application matched by name (several names per row, so one + fader can cover every game or two music players), or a hardware capture + device such as a headset microphone. One row may be the catch-all for + anything unmatched. +- **Per-mix output** — every mix chooses its own output device, or none at all + for a mix that exists only to be captured. A mix keeps playing when the + window is closed. - **Microphone controls** — Gain, mute (syncs with hardware button) - **Headphone controls** — Volume (syncs with hardware knob), low impedance mode - **Hardware sync** — 10 Hz polling keeps the app in sync with physical controls @@ -99,9 +110,43 @@ wavexlr/ tray.py — StatusNotifierItem tray icon via D-Bus audio.py — PipeWire capture keepalive (fixes firmware race condition) daemon.py — Systemd service entry point - setup.py — First-run udev + systemd setup + setup.py — First-run udev + systemd setup, generated PipeWire config + mixer.py — The router: intake sinks, per-cell loopbacks, stream claiming + mixes.py — Mix definitions store (~/.config/openwave/mixdefs.json) + sources.py — Source definitions store (~/.config/openwave/sources.json) + mixmatrix.py — The sources x mixes grid widget + mixdialog.py — Create/rename a mix + sourcedialog.py — Add or edit a source + meter.py — Level metering via pw-cat + service.py — systemd/runit unit management + paths.py — Install-prefix resolution ``` +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) explains the routing model: why an +application's audio is moved rather than copied, how trim and send compose, and +why every stream gets exactly one owner. + +## Development + +Run from a checkout without installing: + +```bash +python3 -m wavexlr +``` + +The tests cover the backend — matching, the stores, state migration, the +generated config and the device scaling. They import neither GTK nor a running +PipeWire, so they need no display, no audio server and no hardware: + +```bash +python3 -m unittest discover -s tests -t . +``` + +The GUI, the USB protocol and the routing itself are not unit-tested; those are +verified against real hardware. `python3 -m wavexlr.probe dump` reads a +connected device and is the fastest way to check a profile — quit OpenWave +first, since the firmware serves one process at a time. + ## Credits USB protocol reverse-engineered from the macOS Wave Link application using Frida. Inspired by [GoXLR-on-Linux/goxlr-utility](https://github.com/GoXLR-on-Linux/goxlr-utility). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9d4b5c8 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,144 @@ +# How OpenWave routes audio + +The device half of OpenWave is a USB control panel. The mixing half is a +router built out of ordinary PipeWire objects. This describes the second, +because it is the part that is not obvious from the code. + +## The shape + +``` + application ──move──▶ intake sink ──loopback──▶ mix sink ──loopback──▶ output device + openwave_src_ openwave__mix (your headphones) + ▲ ▲ + trim × send per-mix output +``` + +Everything is a null sink and a `pw-loopback` child process. There is no +custom audio code, no filter graph and no PipeWire module: the whole router is +sinks that discard audio, and loopbacks that carry it between them. + +## Sources are rows, mixes are columns + +A **source** is something that produces audio. Two kinds: + +- **application** — matched by name against live streams. Its audio is *moved* + onto the source's own intake sink, `openwave_src_`. +- **device** — a hardware capture node, such as a headset microphone. Nothing + is moved; the loopback captures the node directly. + +A **mix** is a destination: a null sink that several sources feed and that may +be sent to a real output device, or to nothing at all. + +The matrix is sources × mixes. Each cell is one `pw-loopback` carrying that +source into that mix. + +## Why applications are moved rather than copied + +The obvious implementation captures an application's stream and leaves the +application playing where it was. That is wrong here, and the failure is +subtle: OpenWave's mixes are normally the system default sink, so the +application's own connection already lands in the mix. Capturing it as well +means the audio arrives twice, and the cell's fader only ever attenuates the +copy — pulling it to zero leaves the original at full volume, so the fader +appears broken. + +Moving the stream makes the loopback the only path, which makes the fader +authoritative. + +Consequences worth knowing: + +- The intake sink must exist before anything is moved onto it, and it must be + destroyed when the source stops being routed. Destroying it returns the + parked streams to the default sink; leaving one behind would strand an + application in a sink nothing drains. +- A source that routes nowhere is left alone entirely. Every cell starts at + zero, so capturing an unrouted source would silence the application — the + common case, not an edge one. +- Intake sinks are created with `object.linger=true`, which is mandatory: a + sink created by `pw-cli` dies the instant `pw-cli` exits. They therefore + outlive OpenWave, and are swept at startup if a crash left one behind. + +## Trim and send + +Two levels apply to every source: + +- **Trim** — the source row's own slider. That source's level everywhere. +- **Send** — the cell slider. How much of that source a given mix receives. + +They multiply, and the product is written to the cell's loopback with `wpctl`. + +The trim is deliberately *not* the intake sink's own volume. A null sink's +monitor is taken pre-volume, so a loopback reading that monitor never sees the +change — and the PulseAudio compatibility layer raises the stream to compensate +for a sink turned down, which inverted the control entirely: measured, a sink +at volume 0 produced a monitor at full scale. The loopback volume is the one +control that demonstrably attenuates. + +## Claiming + +Matching alone is not safe to route by. Two sources can match one stream — two +rows naming the same application, or one naming `Chromium` beside one naming +the binary `chromium`. Both would spawn loopbacks into the same mix, PipeWire +would sum them, and two sample-aligned copies is +6 dB. Each fader would +attenuate only its own copy, so pulling one to zero would leave the application +audible and slightly quieter: a broken-looking fader again. + +`claim_streams` gives every stream exactly one owner, in the one place that +decides what gets spawned. Ownership is deterministic — most specific match +wins, ties broken on source id — so it cannot flip between polls and thrash the +loopbacks. + +One source may be marked `catch_all`. It takes whatever no other source +claimed, so an application nobody has named still lands somewhere with a fader +instead of bypassing the matrix. An explicit name always wins. + +## Outputs + +Each mix resolves its own output device: an explicit choice, else the Wave's +own headphone jack, else the system default, else the highest-priority output. +A mix may also be **not monitored**, which is correct for one that exists only +to be captured — a mix feeding a voice application does not want to be in your +ears as well. + +The default-sink step rarely fires: the monitoring mix is usually *itself* the +default sink, and mix sinks are never eligible as outputs, because feeding a +mix into itself would loop. + +Output loopbacks are spawned **detached** — no `PR_SET_PDEATHSIG`, their own +session — so closing the window does not silence the machine. Cell loopbacks +are not: they are mixing state, and are rebuilt on the next start. + +## Where state lives + +| File | Written by | Holds | +|---|---|---| +| `~/.config/openwave/mixdefs.json` | `mixes.py` | mix identity: name, icon, sink, description | +| `~/.config/openwave/sources.json` | `sources.py` | source identity, bindings, trim | +| `~/.config/openwave/mixes.json` | `Mixer` | per-cell levels, plus a reserved `outputs` map | +| `~/.config/openwave/ui-state.json` | `app.py` | window geometry, gain lock | +| `~/.config/pipewire/pipewire.conf.d/52-openwave-mixes.conf` | generated | one null sink per mix | + +Mix identity and per-cell levels are deliberately separate files: sharing one +would let a slider move clobber a definition. + +`Mixer._state` is read once at construction and rewritten wholesale on save, +so an external process writing `mixes.json` while OpenWave runs will be +silently overwritten. Anything wanting to drive OpenWave from outside needs a +real interface, not a file. + +## Naming + +Nodes are addressed by `node.name`, never by id — ids are reassigned across +restarts. + +| Pattern | What it is | +|---|---| +| `openwave__mix` | a mix's null sink | +| `openwave_src_` | an application source's intake sink | +| `openwave_loop__` | an application cell | +| `openwave_loop_dev__to_` | a capture-device cell | +| `openwave_loop_mic_to_` | the built-in microphone row | +| `openwave_loop_out_` | a mix's output, detached | + +Every loopback keeps the `openwave_loop_` prefix, because startup sweeps +orphans by matching it. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..4a84f39 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,60 @@ +"""Shared helpers: keep every test off the user's real configuration.""" + +import contextlib +import os +import tempfile + +from wavexlr import mixes, sources +from wavexlr import mixer as mixer_mod + + +@contextlib.contextmanager +def temp_config(): + """Point every JSON store at a throwaway directory. + + The stores address their files through module-level constants, so a test + that forgot this would read and overwrite the real ~/.config/openwave. + """ + with tempfile.TemporaryDirectory() as tmp: + originals = ( + sources.CONFIG_PATH, mixes.CONFIG_PATH, mixer_mod.CONFIG_PATH, + ) + sources.CONFIG_PATH = os.path.join(tmp, "sources.json") + mixes.CONFIG_PATH = os.path.join(tmp, "mixdefs.json") + mixer_mod.CONFIG_PATH = os.path.join(tmp, "mixes.json") + try: + yield tmp + finally: + (sources.CONFIG_PATH, mixes.CONFIG_PATH, + mixer_mod.CONFIG_PATH) = originals + + +def stream(app_name="", binary="", node_name="", stream_id=1): + """A stream record shaped like list_audio_streams() returns.""" + return { + "id": stream_id, "app_name": app_name, + "binary": binary, "node_name": node_name, "serial": 1000 + stream_id, + } + + +def bare_mixer(**attrs): + """A Mixer with no worker thread, for exercising pure logic. + + Mixer.__init__ starts a background thread and probes hardware; none of + that is wanted here, and a leaked worker would outlive the test. + """ + import threading + mx = object.__new__(mixer_mod.Mixer) + mx._lock = threading.Lock() + mx._state = {} + mx._sources = {} + mx._mixes = {} + mx._procs = {} + mx._intakes = set() + mx._live_captures = frozenset() + mx.mic = None + mx.hp = None + mx._started = False + for key, value in attrs.items(): + setattr(mx, key, value) + return mx diff --git a/tests/test_config_render.py b/tests/test_config_render.py new file mode 100644 index 0000000..507ae18 --- /dev/null +++ b/tests/test_config_render.py @@ -0,0 +1,86 @@ +"""The generated PipeWire config, and the device profiles behind it.""" + +import re +import unittest + +from wavexlr import mixes, profiles, setup + + +class SpaEscaping(unittest.TestCase): + def test_a_plain_value_is_quoted(self): + self.assertEqual(setup._spa_str("OpenWave Music"), '"OpenWave Music"') + + def test_quotes_are_escaped(self): + # A mix name is typed by the user and reaches both the config and a + # pw-cli argument; an unescaped quote truncates the property and + # corrupts every sink defined after it. + self.assertEqual(setup._spa_str('My "Mix"'), '"My \\"Mix\\""') + + def test_backslashes_are_escaped(self): + self.assertEqual(setup._spa_str("a\\b"), '"a\\\\b"') + + +class RenderedConfig(unittest.TestCase): + def setUp(self): + self.rendered = setup.render_mixes_conf(mixes.DEFAULT_MIXES) + + def test_it_declares_every_mix(self): + names = re.findall(r"node\.name\s*=\s*(\S+)", self.rendered) + self.assertEqual(names, ["openwave_personal_mix", "openwave_chat_mix", + "openwave_record_mix"]) + + def test_descriptions_are_separate_from_display_names(self): + # Renaming a mix in the UI must not rename what PipeWire publishes. + descs = re.findall(r'node\.description\s*=\s*"([^"]+)"', self.rendered) + self.assertEqual(descs, ["OpenWave Personal Mix", "OpenWave Chat Mix", + "OpenWave Record Mix"]) + + def test_every_sink_lingers_and_exposes_a_post_volume_monitor(self): + # object.linger keeps the sink alive without its creator; + # monitor.channel-volumes is what makes a sink's volume affect what is + # captured from it. + self.assertEqual(self.rendered.count("object.linger = true"), 3) + self.assertEqual( + self.rendered.count("monitor.channel-volumes = true"), 3) + + def test_it_is_marked_generated(self): + self.assertIn(setup.GENERATED_MARKER, self.rendered) + + def test_a_hostile_name_cannot_break_the_syntax(self): + hostile = {"x": { + "id": "x", "sink": "openwave_mix_x", "name": "n", "subtitle": "", + "description": 'Evil " } node.name = pwned', "icon_name": "i", + }} + line = [ln for ln in setup.render_mixes_conf(hostile).splitlines() + if "node.description" in ln][0] + self.assertNotIn("pwned", line.split("=", 1)[0]) + self.assertTrue(line.strip().endswith('"')) + + +class DeviceProfiles(unittest.TestCase): + def test_the_mk2_is_registered(self): + pids = {p.pid for p in profiles.PROFILES} + self.assertIn(0x00A6, pids) + + def test_the_mk2_clones_the_original_layout(self): + mk2 = next(p for p in profiles.PROFILES if p.pid == 0x00A6) + xlr = next(p for p in profiles.PROFILES if p.pid == 0x007D) + for field in ("off_gain", "off_mute", "off_hp_vol", "off_low_z", + "config_len", "windex", "gain_max", "gain_scale"): + self.assertEqual(getattr(mk2, field), getattr(xlr, field), field) + + def test_the_mk2_keeps_its_own_identity(self): + mk2 = next(p for p in profiles.PROFILES if p.pid == 0x00A6) + self.assertEqual(mk2.key, "wave_xlr_mk2") + self.assertIn("XLR Dock", mk2.card_match) + + def test_gain_is_expressed_in_dB_not_raw_units(self): + # Measured against the card's ALSA control: 256 raw units per dB. + for prof in profiles.PROFILES: + self.assertTrue(prof.gain_scale, f"{prof.display_name} has no scale") + xlr = next(p for p in profiles.PROFILES if p.pid == 0x007D) + self.assertEqual(xlr.gain_max / xlr.gain_scale, 80.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_device_scaling.py b/tests/test_device_scaling.py new file mode 100644 index 0000000..979f653 --- /dev/null +++ b/tests/test_device_scaling.py @@ -0,0 +1,77 @@ +"""Firmware-to-ALSA conversions for the Wave's own controls.""" + +import unittest + +from wavexlr import device + + +class GainScaling(unittest.TestCase): + SCALE = 256 # raw units per dB, measured against the ALSA control + + def test_it_matches_the_measured_mapping(self): + # Driven on hardware at four points; ALSA counts half-dB steps. + for db, raw in ((20, 0x1400), (40, 0x2800), (60, 0x3C00), (75, 0x4B00)): + self.assertEqual(device._fw_gain_to_alsa(raw, self.SCALE), + int(db / 0.5), f"{db} dB") + + def test_it_does_not_truncate_above_forty_dB(self): + # The old constant clamped to 80 steps, which is 40 dB -- correct for + # the Wave:3 and half of what a Wave XLR can do. + self.assertEqual(device._fw_gain_to_alsa(75 * self.SCALE, self.SCALE), 150) + + def test_it_never_returns_a_negative_step(self): + self.assertEqual(device._fw_gain_to_alsa(-1000, self.SCALE), 0) + + +class HeadphoneScaling(unittest.TestCase): + SCALE = 256 + + def test_zero_dB_is_the_top_of_the_range(self): + self.assertEqual(device._fw_hp_to_alsa(0, self.SCALE), 120) + + def test_it_saturates_at_the_bottom(self): + # The driver caps at 0, which is -60 dB; anything below saturates. + self.assertEqual(device._fw_hp_to_alsa(-100 * self.SCALE, self.SCALE), 0) + + def test_it_round_trips(self): + for db in (0, -10, -30, -60): + alsa = device._fw_hp_to_alsa(db * self.SCALE, self.SCALE) + self.assertAlmostEqual( + device._alsa_hp_to_fw(alsa, self.SCALE) / self.SCALE, db, places=1) + + +class ControlRanges(unittest.TestCase): + def test_an_unreadable_control_uses_the_stated_fallback(self): + # The range is read from the driver; a card that cannot answer must + # not silently clamp to a range belonging to another device. + original = device._amixer + device._amixer = lambda *a, **k: "" + device._ALSA_CTL_MAX.clear() + try: + self.assertEqual(device._alsa_ctl_max("99", 6, 150), 150) + self.assertEqual(device._alsa_ctl_max("99", 4, 120), 120) + finally: + device._amixer = original + device._ALSA_CTL_MAX.clear() + + def test_it_parses_and_caches_the_reported_maximum(self): + calls = [] + + def fake(card, *args): + calls.append(args) + return " ; type=INTEGER,access=rw---R--,values=1,min=0,max=150,step=0\n" + + original = device._amixer + device._amixer = fake + device._ALSA_CTL_MAX.clear() + try: + self.assertEqual(device._alsa_ctl_max("3", 6, 999), 150) + self.assertEqual(device._alsa_ctl_max("3", 6, 999), 150) + self.assertEqual(len(calls), 1, "the range should be read once") + finally: + device._amixer = original + device._ALSA_CTL_MAX.clear() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 0000000..aedfa3e --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,105 @@ +"""Which stream belongs to which source. + +The rules here are the ones a regression would break silently: audio would +still play, just from the wrong row, or from two rows at once. +""" + +import unittest + +from wavexlr import sources +from wavexlr.mixer import claim_streams, stream_matches + +from .support import stream + + +class StreamMatching(unittest.TestCase): + def test_matches_on_application_name(self): + src = {"match_app_names": ["Spotify"]} + self.assertTrue(stream_matches(src, stream(app_name="Spotify"))) + + def test_ignores_case_and_surrounding_space(self): + src = {"match_app_names": ["spotify"]} + self.assertTrue(stream_matches(src, stream(app_name=" SPOTIFY "))) + + def test_matches_on_process_binary(self): + # Discord publishes "WEBRTC VoiceEngine" as its application name and + # runs as "Discord"; only the binary identifies it. + src = {"match_app_names": ["Discord"]} + self.assertTrue( + stream_matches(src, stream(app_name="WEBRTC VoiceEngine", + binary="Discord")) + ) + + def test_rejects_substrings(self): + # "Chrome" must not swallow every Chromium stream. + src = {"match_app_names": ["Chrome"]} + self.assertFalse(stream_matches(src, stream(app_name="Chromium"))) + + def test_reads_the_superseded_singular_key(self): + # Records written before multi-application sources existed. + legacy = {"match_app_name": "Spotify"} + self.assertEqual(sources.bindings(legacy), ["Spotify"]) + self.assertTrue(stream_matches(legacy, stream(app_name="Spotify"))) + + def test_a_source_bound_to_nothing_matches_nothing(self): + self.assertFalse(stream_matches({}, stream(app_name="Spotify"))) + self.assertFalse( + stream_matches({"match_app_names": []}, stream(app_name="Spotify")) + ) + + +class Claiming(unittest.TestCase): + def test_a_stream_has_exactly_one_owner(self): + # Two sources naming the same application would otherwise both route + # it into the same mix, summing to roughly +6 dB, and neither fader + # would appear to work. + srcs = { + "a": {"match_app_names": ["Spotify"]}, + "b": {"match_app_names": ["spotify"]}, + } + claims = claim_streams(srcs, {1: stream(app_name="Spotify")}) + self.assertEqual(sum(len(v) for v in claims.values()), 1) + + def test_ownership_is_stable_across_calls(self): + # Ownership that flipped between polls would thrash the loopbacks. + srcs = { + "a": {"match_app_names": ["Spotify"]}, + "b": {"match_app_names": ["spotify"]}, + } + streams = {1: stream(app_name="Spotify")} + first = claim_streams(srcs, streams) + for _ in range(5): + self.assertEqual(claim_streams(srcs, streams), first) + + def test_a_named_source_beats_the_catch_all(self): + srcs = { + "system": {"match_app_names": ["gnome-shell"], "catch_all": True}, + "music": {"match_app_names": ["Spotify"]}, + } + claims = claim_streams(srcs, {1: stream(app_name="Spotify")}) + self.assertEqual(claims["music"], {1}) + self.assertEqual(claims["system"], set()) + + def test_the_catch_all_takes_what_nothing_else_named(self): + srcs = { + "system": {"match_app_names": ["gnome-shell"], "catch_all": True}, + "music": {"match_app_names": ["Spotify"]}, + } + claims = claim_streams(srcs, {1: stream(app_name="SomeUnknownGame")}) + self.assertEqual(claims["system"], {1}) + + def test_without_a_catch_all_an_unmatched_stream_is_unowned(self): + srcs = {"music": {"match_app_names": ["Spotify"]}} + claims = claim_streams(srcs, {1: stream(app_name="Nothing")}) + self.assertEqual(sum(len(v) for v in claims.values()), 0) + + def test_every_source_gets_an_entry(self): + # Callers index the result directly; a missing key would be a KeyError + # on the routing path. + srcs = {"a": {"match_app_names": ["X"]}, "b": {}} + claims = claim_streams(srcs, {}) + self.assertEqual(set(claims), {"a", "b"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mixer_state.py b/tests/test_mixer_state.py new file mode 100644 index 0000000..b787763 --- /dev/null +++ b/tests/test_mixer_state.py @@ -0,0 +1,161 @@ +"""Mixer state: migration, output resolution, and the trim-and-send arithmetic.""" + +import json +import unittest + +from wavexlr import mixer as mixer_mod +from wavexlr.mixer import OUTPUT_AUTO, OUTPUT_NONE, OUTPUTS_STATE_KEY + +from .support import bare_mixer, temp_config + + +class StateMigration(unittest.TestCase): + def test_the_legacy_scalar_folds_into_the_per_mix_mapping(self): + mx = bare_mixer(_state={ + "mic.personal": {"volume": 0.5, "muted": False}, + "output": "alsa_output.FOO", + }) + self.assertTrue(mx._migrate_state()) + self.assertEqual(mx._state[OUTPUTS_STATE_KEY]["personal"], + "alsa_output.FOO") + + def test_cells_survive_the_migration(self): + mx = bare_mixer(_state={ + "mic.personal": {"volume": 0.5, "muted": False}, + "output": "alsa_output.FOO", + }) + mx._migrate_state() + self.assertEqual(mx.get_cell("mic", "personal"), + {"volume": 0.5, "muted": False}) + + def test_an_existing_per_mix_choice_wins_over_the_scalar(self): + # The mapping is the newer of the two; the scalar is only a fallback. + mx = bare_mixer(_state={ + "output": "alsa_output.OLD", + OUTPUTS_STATE_KEY: {"personal": "alsa_output.NEW"}, + }) + mx._migrate_state() + self.assertEqual(mx._state[OUTPUTS_STATE_KEY]["personal"], + "alsa_output.NEW") + + def test_migrating_twice_changes_nothing_further(self): + mx = bare_mixer(_state={"output": "alsa_output.FOO"}) + mx._migrate_state() + snapshot = dict(mx._state[OUTPUTS_STATE_KEY]) + mx._migrate_state() + self.assertEqual(mx._state[OUTPUTS_STATE_KEY], snapshot) + + def test_load_state_rejects_a_non_object_payload(self): + # _migrate_state mutates whatever this returns, so a list or a string + # reaching it would raise on the first .get(). + with temp_config(): + for payload in ("[1, 2, 3]", '"a string"', "not json"): + with open(mixer_mod.CONFIG_PATH, "w") as f: + f.write(payload) + self.assertEqual(bare_mixer()._load_state(), {}, + f"payload {payload!r} was not rejected") + + def test_load_state_reads_a_well_formed_file(self): + with temp_config(): + with open(mixer_mod.CONFIG_PATH, "w") as f: + json.dump({"mic.personal": {"volume": 0.5, "muted": False}}, f) + self.assertEqual( + bare_mixer()._load_state()["mic.personal"]["volume"], 0.5) + + def test_cells_excludes_reserved_keys(self): + mx = bare_mixer(_state={ + "mic.personal": {"volume": 1.0, "muted": False}, + "output": "auto", + OUTPUTS_STATE_KEY: {"personal": "auto"}, + }) + self.assertEqual(list(mx.cells()), ["mic.personal"]) + + +class DefaultOutput(unittest.TestCase): + def test_the_first_mix_monitors_by_default(self): + mx = bare_mixer(_mixes={"personal": {}, "chat": {}}) + self.assertEqual(mx._default_output_for("personal"), OUTPUT_AUTO) + self.assertEqual(mx._default_output_for("chat"), OUTPUT_NONE) + + def test_it_follows_the_order_rather_than_the_name(self): + # The built-in mixes are deletable, so keying on the literal + # "personal" would leave nothing monitored once it is gone. + mx = bare_mixer(_mixes={"chat": {}, "record": {}}) + self.assertEqual(mx._default_output_for("chat"), OUTPUT_AUTO) + self.assertEqual(mx._default_output_for("record"), OUTPUT_NONE) + + def test_a_stored_choice_beats_the_default(self): + mx = bare_mixer( + _mixes={"personal": {}, "chat": {}}, + _state={OUTPUTS_STATE_KEY: {"chat": "alsa_output.X"}}, + ) + self.assertEqual(mx.get_output("chat"), "alsa_output.X") + + def test_output_none_resolves_to_no_sink(self): + mx = bare_mixer( + _mixes={"personal": {}}, + _state={OUTPUTS_STATE_KEY: {"personal": OUTPUT_NONE}}, + ) + # Passing sinks in keeps this off the live system. + self.assertIsNone(mx.resolve_output("personal", sinks=[], default_sink=None)) + + def test_resolution_falls_through_an_absent_device(self): + sinks = [{"name": "alsa_output.LIVE", "description": "Live", "priority": 100}] + mx = bare_mixer( + _mixes={"personal": {}}, + _state={OUTPUTS_STATE_KEY: {"personal": "alsa_output.UNPLUGGED"}}, + ) + self.assertEqual( + mx.resolve_output("personal", sinks=sinks, default_sink=None), + "alsa_output.LIVE", + ) + + def test_auto_prefers_the_highest_priority_output(self): + sinks = [ + {"name": "alsa_output.LOW", "description": "Low", "priority": 100}, + {"name": "alsa_output.HIGH", "description": "High", "priority": 900}, + ] + mx = bare_mixer(_mixes={"personal": {}}) + self.assertEqual( + mx.resolve_output("personal", sinks=sinks, default_sink=None), + "alsa_output.HIGH", + ) + + +class SourceTrim(unittest.TestCase): + def test_absent_level_is_unity(self): + mx = bare_mixer(_sources={"music": {}}) + self.assertEqual(mx._source_gain("music"), 1.0) + + def test_a_muted_source_contributes_nothing(self): + mx = bare_mixer(_sources={"music": {"level": 0.8, "muted": True}}) + self.assertEqual(mx._source_gain("music"), 0.0) + + def test_the_level_is_clamped(self): + mx = bare_mixer(_sources={"a": {"level": 5.0}, "b": {"level": -2.0}}) + self.assertEqual(mx._source_gain("a"), 1.0) + self.assertEqual(mx._source_gain("b"), 0.0) + + def test_a_nonsense_level_falls_back_to_unity(self): + mx = bare_mixer(_sources={"music": {"level": "loud"}}) + self.assertEqual(mx._source_gain("music"), 1.0) + + def test_an_unknown_source_is_unity(self): + # The built-in microphone row is not in the sources store. + self.assertEqual(bare_mixer()._source_gain("mic"), 1.0) + + +class SinkNaming(unittest.TestCase): + def test_intake_names_are_derived_from_the_source_id(self): + self.assertEqual(mixer_mod.source_sink_name("music"), + "openwave_src_music") + + def test_output_loopback_keys_are_recognised(self): + # stop() and the atexit handler skip these so a mix keeps playing. + self.assertTrue(mixer_mod._is_output_key(("output", "personal"))) + self.assertFalse(mixer_mod._is_output_key(("mic", "personal"))) + self.assertFalse(mixer_mod._is_output_key(("src", "mix", 7))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stores.py b/tests/test_stores.py new file mode 100644 index 0000000..1cdb139 --- /dev/null +++ b/tests/test_stores.py @@ -0,0 +1,157 @@ +"""The JSON stores. A bug here loses configuration rather than misroutes audio.""" + +import json +import os +import unittest + +from wavexlr import mixes, sources + +from .support import temp_config + + +class MixStore(unittest.TestCase): + def test_first_run_seeds_the_built_ins(self): + with temp_config(): + seeded = mixes.load_seeded() + self.assertEqual(list(seeded), ["personal", "chat", "record"]) + self.assertTrue(os.path.exists(mixes.CONFIG_PATH)) + + def test_seeded_sinks_keep_their_legacy_names(self): + # Other applications target these by name; renaming one silently + # breaks an OBS or Discord capture pointed at it. + with temp_config(): + seeded = mixes.load_seeded() + self.assertEqual( + [m["sink"] for m in seeded.values()], + ["openwave_personal_mix", "openwave_chat_mix", + "openwave_record_mix"], + ) + + def test_an_empty_store_is_respected_not_reseeded(self): + # Deleting every mix is a decision, not a corruption. + with temp_config(): + with open(mixes.CONFIG_PATH, "w") as f: + json.dump({}, f) + self.assertEqual(mixes.load_seeded(), {}) + + def test_a_corrupt_store_is_quarantined_and_replaced(self): + with temp_config(): + with open(mixes.CONFIG_PATH, "w") as f: + f.write("not json at all") + seeded = mixes.load_seeded() + self.assertEqual(list(seeded), ["personal", "chat", "record"]) + self.assertTrue(os.path.exists(mixes.CONFIG_PATH + ".corrupt")) + + def test_a_non_object_payload_counts_as_corrupt(self): + with temp_config(): + with open(mixes.CONFIG_PATH, "w") as f: + json.dump([1, 2, 3], f) + self.assertEqual(list(mixes.load_seeded()), + ["personal", "chat", "record"]) + + def test_update_cannot_change_id_or_sink(self): + # The id prefixes every "." cell key and the sink is what + # other applications target; both are structural. + with temp_config(): + store = mixes.load_seeded() + mixes.update(store, "chat", name="Stream", id="HACK", sink="HACK") + self.assertEqual(store["chat"]["id"], "chat") + self.assertEqual(store["chat"]["sink"], "openwave_chat_mix") + self.assertEqual(store["chat"]["name"], "Stream") + + def test_a_new_mix_gets_an_interpolation_safe_id(self): + # The id is interpolated unquoted into pw-loopback properties and into + # dot-separated cell keys. + with temp_config(): + mix = mixes.new_mix(name="My \"Odd\" Mix / 2") + self.assertRegex(mix["id"], r"^[a-z0-9_]+$") + self.assertNotIn(".", mix["id"]) + self.assertTrue(mix["sink"].startswith("openwave_mix_")) + + def test_stale_version_subtitles_are_replaced_on_load(self): + with temp_config(): + store = mixes.load_seeded() + store["chat"]["subtitle"] = "To voice apps (v0.3.0)" + mixes.save(store) + self.assertEqual(mixes.load_seeded()["chat"]["subtitle"], + "Send to voice apps") + + def test_a_user_edited_subtitle_is_left_alone(self): + with temp_config(): + store = mixes.load_seeded() + store["chat"]["subtitle"] = "my own words" + mixes.save(store) + self.assertEqual(mixes.load_seeded()["chat"]["subtitle"], + "my own words") + + +class SourceStore(unittest.TestCase): + def test_first_run_seeds_five_rows(self): + with temp_config(): + seeded = sources.load_seeded() + self.assertEqual(list(seeded), + ["system", "game", "music", "browser", "voice"]) + + def test_exactly_one_row_is_the_catch_all(self): + with temp_config(): + catch = [s for s in sources.load_seeded().values() + if s.get("catch_all")] + self.assertEqual(len(catch), 1) + self.assertEqual(catch[0]["id"], "system") + + def test_an_emptied_store_is_respected(self): + with temp_config(): + with open(sources.CONFIG_PATH, "w") as f: + json.dump({}, f) + self.assertEqual(sources.load_seeded(), {}) + + def test_kind_defaults_to_app_for_older_records(self): + self.assertEqual(sources.kind({"match_app_name": "X"}), + sources.KIND_APP) + self.assertEqual(sources.kind({"kind": "device"}), sources.KIND_DEVICE) + + def test_bindings_round_trip_through_the_entry_field(self): + src = {"match_app_names": ["Spotify", "Tidal"]} + self.assertEqual(sources.format_bindings(src), "Spotify, Tidal") + self.assertEqual(sources.parse_bindings(" Spotify , Tidal ,, "), + ["Spotify", "Tidal"]) + + def test_update_preserves_the_id(self): + # A fresh id would orphan every persisted level for that row. + with temp_config(): + store = sources.load_seeded() + sources.update(store, "music", name="Tunes", id="HACK") + self.assertEqual(store["music"]["id"], "music") + self.assertEqual(store["music"]["name"], "Tunes") + + +class Reordering(unittest.TestCase): + def setUp(self): + self.order = ["system", "game", "music", "browser", "voice"] + self.store = {k: {"id": k} for k in self.order} + + def test_moves_one_place(self): + with temp_config(): + moved = sources.reorder(self.store, "voice", -1) + self.assertEqual(list(moved), + ["system", "game", "music", "voice", "browser"]) + + def test_clamps_rather_than_wrapping(self): + # A row at the top must not jump to the bottom. + with temp_config(): + moved = sources.reorder(self.store, "system", -5) + self.assertEqual(list(moved), self.order) + + def test_a_no_op_move_returns_the_same_order(self): + with temp_config(): + self.assertEqual(list(sources.reorder(self.store, "voice", 1)), + self.order) + + def test_an_unknown_id_is_ignored(self): + with temp_config(): + self.assertEqual(list(sources.reorder(self.store, "nope", 1)), + self.order) + + +if __name__ == "__main__": + unittest.main() From a19525f122222755980a0f82d201e6c4507de53b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:07:53 -0500 Subject: [PATCH 26/99] Publish each mix as a capture source, and keep it linked 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, _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. --- docs/ARCHITECTURE.md | 19 +++++++++++-- tests/test_mixer_state.py | 8 ++++++ wavexlr/mixer.py | 58 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d4b5c8..3eaaccb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,6 +108,20 @@ Output loopbacks are spawned **detached** — no `PR_SET_PDEATHSIG`, their own session — so closing the window does not silence the machine. Cell loopbacks are not: they are mixing state, and are rebuilt on the next start. +## Mixes as capture sources + +Every mix is also published as an ordinary capture source, `_source`, so +a mix can be selected as a microphone in a voice application. Its monitor +already carries the same audio, but Discord and others filter monitor sources +out of their input lists entirely, so a mix chosen that way is unselectable. + +The capture side is re-linked on every reconcile rather than once at creation. +Installing mixes destroys and recreates their sinks, and the new sink is a +different node: a loopback pinned to the old one keeps running against a dead +link, so the source still exists, is still selectable, and is silent. Nothing +else repairs that and nothing reports it, which is exactly the kind of failure +that looks like "Discord cannot hear me" and has no visible cause. + ## Where state lives | File | Written by | Holds | @@ -139,6 +153,7 @@ restarts. | `openwave_loop_dev__to_` | a capture-device cell | | `openwave_loop_mic_to_` | the built-in microphone row | | `openwave_loop_out_` | a mix's output, detached | +| `openwave__mix_source` | a mix published as a capture source | -Every loopback keeps the `openwave_loop_` prefix, because startup sweeps -orphans by matching it. +Startup sweeps orphaned loopbacks by matching `openwave_`, which covers both +the `openwave_loop_` cells and the mix capture sources named after their sink. diff --git a/tests/test_mixer_state.py b/tests/test_mixer_state.py index b787763..72026ff 100644 --- a/tests/test_mixer_state.py +++ b/tests/test_mixer_state.py @@ -150,6 +150,14 @@ def test_intake_names_are_derived_from_the_source_id(self): self.assertEqual(mixer_mod.source_sink_name("music"), "openwave_src_music") + def test_mix_source_keeps_the_published_node_name(self): + # An application that has already selected this source stores it by + # name, so changing the pattern silently re-points nothing and the + # user's microphone selection goes dead. + mx = bare_mixer() + self.assertEqual(mx._mix_source_node("openwave_chat_mix"), + "openwave_chat_mix_source") + def test_output_loopback_keys_are_recognised(self): # stop() and the atexit handler skip these so a mix keeps playing. self.assertTrue(mixer_mod._is_output_key(("output", "personal"))) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 810ccd9..ae99e9a 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -639,7 +639,7 @@ def streams(self): # ----- subprocess lifecycle ----- def _spawn_loopback(self, key, capture_source_name, playback_target, - node_name, detach=False): + node_name, detach=False, playback_extra=""): """Spawn a pw-loopback and *manually* link the capture side to `capture_source_name`'s output ports. We disable autoconnect on capture because the session manager will otherwise hijack the loopback by @@ -666,7 +666,8 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, f"node.autoconnect=false node.name={capture_node_name} " "audio.channels=2 audio.position=[FL,FR]", "--playback-props=" - f"target.object={playback_target} node.name={node_name} " + + (f"target.object={playback_target} " if playback_target else "") + + f"node.name={node_name} " + playback_extra + "audio.channels=2 audio.position=[FL,FR]", ], stdout=subprocess.DEVNULL, @@ -926,6 +927,7 @@ def capture_device_present(self, node_name): def _do_start(self): self._sweep_stale_loopbacks() self._sweep_orphan_source_sinks() + self._respawn_mix_sources() self._respawn_all_output_loopbacks() with self._lock: self._streams = {s["id"]: s for s in list_audio_streams()} @@ -948,6 +950,51 @@ def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): key, mix_sink, target, f"openwave_loop_out_{mix_id}", detach=True, ) + def _mix_source_node(self, sink): + """_source -- the name the hand-written config used, so an + application that has already selected it keeps working.""" + return f"{sink}_source" + + def _respawn_mix_sources(self): + """Publish each mix as an ordinary capture source, and keep it linked. + + A mix's monitor already carries its audio, but voice applications -- + Discord among them -- filter monitor sources out of their input lists + entirely, so a mix cannot be selected there. A loopback whose playback + side declares media.class=Audio/Source presents the same audio as a + microphone, which every application lists. + + The capture side is re-linked on every pass, not once at creation. + Installing mixes destroys and recreates their sinks, and the new sink + is a different node: a loopback pinned to the old one keeps running + against a dead link, so the source exists, is selectable, and is + silent. Nothing else repairs that, and nothing reports it. + + priority.session is low so these never win the default-source election + and displace a real microphone. + """ + with self._lock: + mixes = dict(self._mixes) + for mix_id, mix in mixes.items(): + sink = mix.get("sink") + if not sink: + continue + key = ("mixsrc", mix_id) + node_name = self._mix_source_node(sink) + if key not in self._procs: + self._spawn_loopback( + key, sink, None, node_name, + playback_extra=( + "media.class=Audio/Source priority.session=100 " + f'node.description="OpenWave {mix.get("name", mix_id)}" ' + ), + ) + else: + # Already running: re-assert the link in case its sink was + # replaced underneath it. pw-link is harmless when the link + # already exists. + self._link_capture(sink, f"{node_name}_cap", retries=1) + def _respawn_all_output_loopbacks(self): """Retarget every mix, paying the sink-enumeration cost once.""" sinks = list_output_sinks() @@ -1017,7 +1064,10 @@ def _sweep_orphan_source_sinks(self): def _sweep_stale_loopbacks(): try: subprocess.run( - ["pkill", "-f", "pw-loopback.*openwave_loop_"], + # Broader than openwave_loop_: mix capture sources are named + # after their sink, so a narrower pattern would leak one per + # unclean exit. + ["pkill", "-f", "pw-loopback.*openwave_"], capture_output=True, timeout=2, ) except (FileNotFoundError, subprocess.SubprocessError): @@ -1045,6 +1095,8 @@ def _reap_dead(self): def _reconcile_all(self): self._reap_dead() + if self._started: + self._respawn_mix_sources() # Snapshot both axes under the lock: set_sources/set_mixes replace # these dicts from the GTK thread, and a mutation mid-iteration would # raise into _worker_loop's bare except, silently leaving a mix From 6a826a55cc9b7fe6e5d967ec0cdce8d8701cdfe0 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:11:00 -0500 Subject: [PATCH 27/99] Keep an intake sink from becoming the system default 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. --- wavexlr/mixer.py | 31 ++++++++++++++++++++++++++++++- wavexlr/setup.py | 44 +++++++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index ae99e9a..6eeadc7 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -927,6 +927,7 @@ def capture_device_present(self, node_name): def _do_start(self): self._sweep_stale_loopbacks() self._sweep_orphan_source_sinks() + self._rescue_default_sink() self._respawn_mix_sources() self._respawn_all_output_loopbacks() with self._lock: @@ -955,6 +956,32 @@ def _mix_source_node(self, sink): application that has already selected it keeps working.""" return f"{sink}_source" + def _rescue_default_sink(self): + """Move the system default off an intake sink if it landed there. + + Intake sinks are internal, and a session manager choosing one as the + default sends every application into a single source row at that row's + send level -- audio does not stop, it goes quiet and lands in the wrong + place, which reads as "I cannot hear anything" with no obvious cause. + Observed after a PipeWire restart, when the mix sinks were not yet + present for the election. + + priority.session=0 makes it unlikely; this makes it recoverable. + """ + default = _default_sink_name() + if not default or not default.startswith(SOURCE_SINK_PREFIX): + return + with self._lock: + mixes = list(self._mixes.values()) + target = next((m.get("sink") for m in mixes if m.get("sink")), None) + if target is None: + return + try: + subprocess.run(["pactl", "set-default-sink", target], + capture_output=True, timeout=3) + except (FileNotFoundError, subprocess.SubprocessError): + return + def _respawn_mix_sources(self): """Publish each mix as an ordinary capture source, and keep it linked. @@ -1300,7 +1327,9 @@ def _ensure_source_sink(self, source_id, description): from . import setup name = source_sink_name(source_id) try: - setup.create_null_sink(name, f"OpenWave: {description}") + setup.create_null_sink( + name, f"OpenWave: {description}", priority=0, + ) except Exception: return None if source_id not in self._intakes: diff --git a/wavexlr/setup.py b/wavexlr/setup.py index c530915..699e65c 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -222,36 +222,42 @@ def list_sink_names(prefix=""): return names -def create_null_sink(name, description): +def create_null_sink(name, description, priority=None): """Public name for the null-sink creator: mixes are not its only user. Application sources need one each as a stream intake, and they are created on exactly the same terms -- object.linger is mandatory, because without it the node dies the moment pw-cli exits. """ - _create_mix_sink_live(name, description) + _create_mix_sink_live(name, description, priority=priority) -def _create_mix_sink_live(name, description): +def _create_mix_sink_live(name, description, priority=None): """Spawn a null sink immediately so it appears without a PipeWire restart.""" if _mix_sink_exists(name): return - args = ( - "{ " - "factory.name=support.null-audio-sink " - f"node.name={name} " - "node.description=" + _spa_str(description) + " " - "media.class=Audio/Sink " - "audio.position=[FL FR] " - "object.linger=true " - # Without this a null sink's monitor is taken PRE-volume, so setting - # the sink's volume changes nothing downstream -- and at volume 0 the - # monitor was measured at full scale rather than silence. The generated - # config sets it on every mix sink; this path creates the same kind of - # node and must match, or a source's level slider does nothing. - "monitor.channel-volumes=true " - "}" - ) + props = [ + "factory.name=support.null-audio-sink", + f"node.name={name}", + "node.description=" + _spa_str(description), + "media.class=Audio/Sink", + "audio.position=[FL FR]", + # Mandatory: without it the node dies the moment pw-cli exits. + "object.linger=true", + # Without this a null sink's monitor is taken PRE-volume, so the sink's + # volume changes nothing downstream -- measured, at volume 0 the + # monitor read full scale rather than silence. The generated config + # sets it on every mix sink; this path makes the same kind of node and + # must match, or a source's level slider does nothing. + "monitor.channel-volumes=true", + ] + if priority is not None: + # Session priority decides which sink the session manager picks as the + # system default. An intake sink is internal plumbing and must never + # win that election: as the default it swallows every application into + # one source row, at that row's send level. + props.append(f"priority.session={int(priority)}") + args = "{ " + " ".join(props) + " }" try: subprocess.run( ["pw-cli", "create-node", "adapter", args], From 26914c2b3596273634324e47b63999c29f5d4e7e Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:15:58 -0500 Subject: [PATCH 28/99] Cover sink creation and the default-sink rescue 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. --- docs/ARCHITECTURE.md | 15 +++++++++ tests/test_mixer_state.py | 57 ++++++++++++++++++++++++++++++++ tests/test_sink_creation.py | 66 +++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 tests/test_sink_creation.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3eaaccb..541505a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,21 @@ Consequences worth knowing: sink created by `pw-cli` dies the instant `pw-cli` exits. They therefore outlive OpenWave, and are swept at startup if a crash left one behind. +### Intake sinks and the default-sink election + +An intake sink is internal, but it is an ordinary sink as far as the session +manager is concerned, so it can be chosen as the system default. That has been +observed after a PipeWire restart, when the mix sinks were not yet present for +the election to consider. + +The result is worse than an obvious failure: every application lands in one +source row, at that row's send level. Audio does not stop, it goes quiet and +arrives in the wrong place, and nothing on screen looks broken. + +Intake sinks are therefore created with `priority.session=0`, which loses to +everything including every mix, and the default is moved back onto a mix at +startup if one has already won. + ## Trim and send Two levels apply to every source: diff --git a/tests/test_mixer_state.py b/tests/test_mixer_state.py index 72026ff..eed7f9a 100644 --- a/tests/test_mixer_state.py +++ b/tests/test_mixer_state.py @@ -145,6 +145,63 @@ def test_an_unknown_source_is_unity(self): self.assertEqual(bare_mixer()._source_gain("mic"), 1.0) +class DefaultSinkRescue(unittest.TestCase): + """An intake sink must never be where the system sends its audio. + + It is an ordinary sink to the session manager, so it can win the + default-sink election -- observed after a PipeWire restart, when the mix + sinks were not yet present. The result is not silence: every application + lands in one source row at that row's send level, which is quiet and in + the wrong place, and looks like nothing is broken. + """ + + def setUp(self): + self.moved = [] + self._run = mixer_mod.subprocess.run + self._default = mixer_mod._default_sink_name + mixer_mod.subprocess.run = lambda cmd, **kw: self.moved.append(cmd) + + def tearDown(self): + mixer_mod.subprocess.run = self._run + mixer_mod._default_sink_name = self._default + + def _rescue(self, current_default, mixes): + mixer_mod._default_sink_name = lambda: current_default + bare_mixer(_mixes=mixes)._rescue_default_sink() + + def test_it_moves_off_an_intake_sink(self): + self._rescue("openwave_src_system", + {"personal": {"sink": "openwave_personal_mix"}}) + self.assertEqual(self.moved, + [["pactl", "set-default-sink", "openwave_personal_mix"]]) + + def test_it_targets_the_first_mix(self): + self._rescue("openwave_src_music", { + "chat": {"sink": "openwave_chat_mix"}, + "personal": {"sink": "openwave_personal_mix"}, + }) + self.assertEqual(self.moved[0][-1], "openwave_chat_mix") + + def test_it_leaves_a_hardware_default_alone(self): + self._rescue("alsa_output.usb-Headset", + {"personal": {"sink": "openwave_personal_mix"}}) + self.assertEqual(self.moved, []) + + def test_it_leaves_a_mix_default_alone(self): + # The normal, intended state. + self._rescue("openwave_personal_mix", + {"personal": {"sink": "openwave_personal_mix"}}) + self.assertEqual(self.moved, []) + + def test_it_does_nothing_with_no_mix_to_move_to(self): + self._rescue("openwave_src_system", {}) + self.assertEqual(self.moved, []) + + def test_it_tolerates_an_unknown_default(self): + self._rescue(None, {"personal": {"sink": "openwave_personal_mix"}}) + self.assertEqual(self.moved, []) + + class SinkNaming(unittest.TestCase): def test_intake_names_are_derived_from_the_source_id(self): self.assertEqual(mixer_mod.source_sink_name("music"), diff --git a/tests/test_sink_creation.py b/tests/test_sink_creation.py new file mode 100644 index 0000000..de64ead --- /dev/null +++ b/tests/test_sink_creation.py @@ -0,0 +1,66 @@ +"""Null-sink creation and the default-sink election. + +Both are places where a wrong property is silent: the sink appears, audio +flows, and something subtly wrong happens somewhere else. +""" + +import unittest + +from wavexlr import setup + + +class NullSinkProperties(unittest.TestCase): + def setUp(self): + self.calls = [] + self._run = setup.subprocess.run + self._exists = setup._mix_sink_exists + setup._mix_sink_exists = lambda name: False + setup.subprocess.run = lambda cmd, **kw: self.calls.append(cmd) or _Ok() + + def tearDown(self): + setup.subprocess.run = self._run + setup._mix_sink_exists = self._exists + + def _args_for(self, **kwargs): + setup.create_null_sink("openwave_test", "Test", **kwargs) + self.assertTrue(self.calls, "pw-cli was never invoked") + return self.calls[-1][-1] + + def test_it_lingers(self): + # Without this the node dies the instant pw-cli exits, so the sink + # never survives long enough to be used. + self.assertIn("object.linger=true", self._args_for()) + + def test_its_monitor_follows_the_sink_volume(self): + # Otherwise the monitor is taken pre-volume and any level applied to + # the sink has no effect on what a loopback reads from it. + self.assertIn("monitor.channel-volumes=true", self._args_for()) + + def test_priority_is_omitted_unless_asked_for(self): + # A mix sink must stay eligible to be the system default. + self.assertNotIn("priority.session", self._args_for()) + + def test_an_intake_can_be_made_ineligible(self): + # An intake winning the default-sink election sends every application + # into one source row at that row's send level. + self.assertIn("priority.session=0", self._args_for(priority=0)) + + def test_the_description_is_quoted(self): + setup.create_null_sink("openwave_test", 'Odd " name') + args = self.calls[-1][-1] + self.assertIn('node.description="Odd \\" name"', args) + + def test_the_property_list_is_balanced(self): + args = self._args_for(priority=0) + self.assertTrue(args.startswith("{ "), args[:20]) + self.assertTrue(args.endswith(" }"), args[-20:]) + + +class _Ok: + returncode = 0 + stdout = "" + stderr = "" + + +if __name__ == "__main__": + unittest.main() From 6cb44ba74522623fcc1eea229d6c347914dd3e5f Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:29:28 -0500 Subject: [PATCH 29/99] Identify every node OpenWave creates Closes the ask in issue #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. --- wavexlr/meter.py | 9 +++++++++ wavexlr/mixer.py | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/wavexlr/meter.py b/wavexlr/meter.py index 1bffe39..b9ef0d7 100644 --- a/wavexlr/meter.py +++ b/wavexlr/meter.py @@ -7,6 +7,7 @@ to keep concerns separate. """ +import json import os import struct import subprocess @@ -41,6 +42,14 @@ def start(self, source_id, source_node_name, callback): [ "pw-cat", "--record", "--target", source_node_name, + # Labelled so a level tap is identifiable in a mixer or a + # monitoring script. Unlabelled these appear as bare + # "pw-cat" entries indistinguishable from anyone else's. + "--properties", json.dumps({ + "node.name": f"openwave_meter_{source_id}", + "node.description": f"OpenWave level meter ({source_id})", + "application.name": "OpenWave", + }), "--rate", str(self.SAMPLE_RATE), "--channels", "1", "--format", "s16", diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 6eeadc7..4144056 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -639,7 +639,8 @@ def streams(self): # ----- subprocess lifecycle ----- def _spawn_loopback(self, key, capture_source_name, playback_target, - node_name, detach=False, playback_extra=""): + node_name, detach=False, playback_extra="", + description=None): """Spawn a pw-loopback and *manually* link the capture side to `capture_source_name`'s output ports. We disable autoconnect on capture because the session manager will otherwise hijack the loopback by @@ -658,16 +659,27 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, if key in self._procs: return capture_node_name = f"{node_name}_cap" + # Both halves are labelled. Unlabelled they show up as + # "pw-loopback-542152" in every mixer and monitoring tool, which makes + # OpenWave's plumbing indistinguishable from anyone else's and + # impossible to filter on. + label = description or node_name + ident = f'application.name=OpenWave node.description="{label}" ' + cap_ident = f'application.name=OpenWave node.description="{label} (capture)" ' + try: proc = subprocess.Popen( [ "pw-loopback", "--capture-props=" f"node.autoconnect=false node.name={capture_node_name} " + + cap_ident + "audio.channels=2 audio.position=[FL,FR]", "--playback-props=" + (f"target.object={playback_target} " if playback_target else "") - + f"node.name={node_name} " + playback_extra + + + f"node.name={node_name} " + + ("" if "node.description" in playback_extra else ident) + + playback_extra + "audio.channels=2 audio.position=[FL,FR]", ], stdout=subprocess.DEVNULL, @@ -947,8 +959,10 @@ def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): target = self.resolve_output(mix_id, sinks=sinks, default_sink=default_sink) if target is None: return + mix_name = (self._mixes.get(mix_id) or {}).get("name", mix_id) self._spawn_loopback( key, mix_sink, target, f"openwave_loop_out_{mix_id}", detach=True, + description=f"{mix_name} \u2192 output", ) def _mix_source_node(self, sink): @@ -1011,6 +1025,7 @@ def _respawn_mix_sources(self): if key not in self._procs: self._spawn_loopback( key, sink, None, node_name, + description=f"{mix.get('name', mix_id)} (capture source)", playback_extra=( "media.class=Audio/Source priority.session=100 " f'node.description="OpenWave {mix.get("name", mix_id)}" ' @@ -1197,7 +1212,14 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted self._destroy_loopback(key) return if key not in self._procs: - self._spawn_loopback(key, capture_node, mix_sink, node_name) + with self._lock: + src_name = (self._sources.get(source_id) or {}).get( + "name", "Microphone" if source_id == "mic" else source_id) + mix_name = (self._mixes.get(mix_id) or {}).get("name", mix_id) + self._spawn_loopback( + key, capture_node, mix_sink, node_name, + description=f"{src_name} \u2192 {mix_name}", + ) node_id = _node_id_by_name(node_name) if node_id is not None: # cell fader x source trim: the row slider scales this source @@ -1256,7 +1278,11 @@ def _reconcile_app_cell(self, source_id, mix_id, volume, muted): self._destroy_loopback(key) return if key not in self._procs: - self._spawn_loopback(key, intake, mix_sink, node_name) + mix_name = (self._mixes.get(mix_id) or {}).get("name", mix_id) + self._spawn_loopback( + key, intake, mix_sink, node_name, + description=f"{source.get('name', source_id)} \u2192 {mix_name}", + ) node_id = _node_id_by_name(node_name) if node_id is not None: # cell fader x source trim: the row slider scales this source From bdf8aac465cae8ed49ac803cfb7ece6b8d206653 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:43:00 -0500 Subject: [PATCH 30/99] Add 48V phantom power control Closes the ask in issue #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 #6, which nobody has confirmed. --- tests/test_config_render.py | 30 ++++++++++++++++++++++++++++++ wavexlr/app.py | 18 ++++++++++++++++++ wavexlr/device.py | 20 ++++++++++++++++++++ wavexlr/profiles.py | 10 ++++++++++ 4 files changed, 78 insertions(+) diff --git a/tests/test_config_render.py b/tests/test_config_render.py index 507ae18..e9f3d1f 100644 --- a/tests/test_config_render.py +++ b/tests/test_config_render.py @@ -84,3 +84,33 @@ def test_gain_is_expressed_in_dB_not_raw_units(self): if __name__ == "__main__": unittest.main() + + +class PhantomPower(unittest.TestCase): + """48 V phantom, at config byte 6. + + Found by watching the config block while the dial was held on a Wave XLR: + byte 6 flipped with the 48V LED and nothing else moved. Writing it was then + confirmed to move the LED, so it is a control and not a status mirror. + """ + + def test_devices_with_an_xlr_input_expose_it(self): + for pid in (0x007D, 0x00A6): + prof = next(p for p in profiles.PROFILES if p.pid == pid) + self.assertTrue(prof.has_phantom, prof.display_name) + self.assertEqual(prof.off_phantom, 6, prof.display_name) + + def test_a_device_without_an_xlr_input_does_not(self): + # The Wave:3 is a microphone; there is nothing to power. + wave3 = next(p for p in profiles.PROFILES if p.pid == 0x0070) + self.assertFalse(wave3.has_phantom) + self.assertIsNone(wave3.off_phantom) + + def test_it_does_not_collide_with_another_mapped_field(self): + # Byte 6 sits between mute (4) and headphone volume (9); a clash would + # mean toggling phantom silently moved something else. + prof = next(p for p in profiles.PROFILES if p.pid == 0x00A6) + others = {prof.off_gain, prof.off_gain + 1, prof.off_mute, + prof.off_hp_vol, prof.off_hp_vol + 1, prof.off_vol_select, + prof.off_low_z} + self.assertNotIn(prof.off_phantom, others) diff --git a/wavexlr/app.py b/wavexlr/app.py index ccaaa74..ecfd76b 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -329,6 +329,14 @@ def _build_device_pane(self, parent): self.gain_scale.connect("value-changed", self._on_gain_changed) mic_group.add(_slider_row(self.gain_scale)) + phantom_row = Adw.SwitchRow( + title="48V Phantom Power", + subtitle="For condenser microphones. Leave off for dynamic mics.", + ) + phantom_row.connect("notify::active", self._on_phantom_changed) + self.phantom_row = phantom_row + mic_group.add(phantom_row) + knob_row = Adw.ActionRow(title="Knob Controls", subtitle="What the physical knob adjusts") self.knob_label = Gtk.Label(label="Gain") self.knob_label.add_css_class("dim-label") @@ -550,6 +558,7 @@ def _apply_profile(self, profile): self.gain_scale.get_adjustment().set_upper(profile.gain_max) self.knob_row.set_visible(profile.has_vol_select) self.lowz_row.set_visible(profile.has_low_z) + self.phantom_row.set_visible(profile.has_phantom) self.mix_row.set_visible(profile.has_monitor_mix) self.mix_scale_row.set_visible(profile.has_monitor_mix) if profile.has_monitor_mix: @@ -574,6 +583,8 @@ def _apply_state(self, state): self.hp_label.set_label(f"{state['hp_volume_db']:.1f} dB") if "low_impedance" in state: self.lowz_row.set_active(state["low_impedance"]) + if "phantom" in state: + self.phantom_row.set_active(state["phantom"]) if "volume_select" in state: self.knob_label.set_label(KNOB_LABELS.get(state["volume_select"], "Gain")) if "monitor_mix" in state: @@ -806,6 +817,13 @@ def _on_lowz_changed(self, row, _pspec): enabled = row.get_active() self._usb_async(lambda: self.dev.set_low_impedance(enabled), on_error=self._on_usb_error) + def _on_phantom_changed(self, row, _pspec): + if self._updating_ui: + return + enabled = row.get_active() + self._usb_async(lambda: self.dev.set_phantom(enabled), + on_error=self._on_usb_error) + def _on_mix_changed(self, scale): if self._updating_ui or not self.dev.connected: return diff --git a/wavexlr/device.py b/wavexlr/device.py index b6abcb7..bbc7d99 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -234,6 +234,12 @@ def get_hp_volume_db(self): raw = struct.unpack_from(p.hp_fmt, self.read_config(), p.off_hp_vol)[0] return raw / p.hp_scale + def get_phantom(self): + """48 V phantom power state, or None on a device without it.""" + if self.profile.off_phantom is None: + return None + return bool(self.read_config()[self.profile.off_phantom]) + def get_low_impedance(self): if self.profile.off_low_z is None: return None @@ -313,6 +319,8 @@ def get_all(self): state["volume_select"] = p.vol_select_map.get(config[p.off_vol_select], "gain") if p.off_low_z is not None: state["low_impedance"] = bool(config[p.off_low_z]) + if p.off_phantom is not None: + state["phantom"] = bool(config[p.off_phantom]) if p.off_monitor_mix is not None: state["monitor_mix"] = struct.unpack_from(' Date: Sat, 29 Aug 2026 18:47:06 -0500 Subject: [PATCH 31/99] Match an ALSA card to its USB device, not to a name With two Elgato devices connected OpenWave drove the wrong one. _find_card scanned aplay -l for any string in the profile's card_match, and every profile's list ends in "Elgato", so all three resolved to whichever Elgato card came first. On this machine that meant reading the Wave XLR over USB while sending every ALSA control -- mute sync, headphone volume, the gain mirror -- to the XLR Dock. Nothing reports that; the controls simply act on the wrong hardware. Cards are matched on /proc/asound/card*/usbid, which is the device's vid:pid exactly, with usbbus ("bus/device") available to separate two of the same model where vid:pid cannot. Name matching remains only for the case where /proc/asound cannot be read at all. A device that is absent now resolves to None rather than falling through to the name match, which is what made an unplugged Wave:3 resolve to a connected Dock. --- tests/test_device_scaling.py | 50 ++++++++++++++++++++++++++++++++++++ wavexlr/device.py | 50 +++++++++++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/tests/test_device_scaling.py b/tests/test_device_scaling.py index 979f653..f3ea1ba 100644 --- a/tests/test_device_scaling.py +++ b/tests/test_device_scaling.py @@ -1,5 +1,8 @@ """Firmware-to-ALSA conversions for the Wave's own controls.""" +import os +import shutil +import tempfile import unittest from wavexlr import device @@ -75,3 +78,50 @@ def fake(card, *args): if __name__ == "__main__": unittest.main() + + +class CardMatching(unittest.TestCase): + """Which ALSA card belongs to which USB device. + + Name matching was ambiguous the moment two Elgato devices were connected: + every profile's match list ends in "Elgato", so all of them resolved to + whichever Elgato card came first, and OpenWave read one device over USB + while driving the other's ALSA controls. + """ + + def _fake_proc(self, cards): + """Write a throwaway /proc/asound-shaped tree. cards: {n: (usbid, usbbus)}""" + tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp, True) + paths = [] + for n, (usbid, usbbus) in cards.items(): + d = os.path.join(tmp, f"card{n}") + os.makedirs(d) + with open(os.path.join(d, "usbid"), "w") as f: + f.write(usbid + "\n") + with open(os.path.join(d, "usbbus"), "w") as f: + f.write(usbbus + "\n") + paths.append(os.path.join(d, "usbid")) + real_glob = device.glob.glob + device.glob.glob = lambda pat: sorted(paths) if "usbid" in pat else [] + self.addCleanup(setattr, device.glob, "glob", real_glob) + + def test_each_device_resolves_to_its_own_card(self): + self._fake_proc({3: ("0fd9:00a6", "011/007"), 4: ("0fd9:007d", "001/036")}) + self.assertEqual(device._find_card(("Elgato",), vid=0x0FD9, pid=0x007D), "4") + self.assertEqual(device._find_card(("Elgato",), vid=0x0FD9, pid=0x00A6), "3") + + def test_an_absent_device_resolves_to_nothing(self): + # Not to whichever Elgato card happens to be present. + self._fake_proc({3: ("0fd9:00a6", "011/007")}) + self.assertIsNone(device._find_card(("Elgato",), vid=0x0FD9, pid=0x0070)) + + def test_usbbus_separates_two_of_the_same_model(self): + self._fake_proc({3: ("0fd9:00a6", "011/007"), 5: ("0fd9:00a6", "002/004")}) + self.assertEqual( + device._find_card(("Elgato",), vid=0x0FD9, pid=0x00A6, usbbus="002/004"), + "5") + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/device.py b/wavexlr/device.py index bbc7d99..a43fee3 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -10,6 +10,8 @@ """ import ctypes +import glob +import os import ctypes.util import re import struct @@ -45,8 +47,48 @@ _lib.libusb_init(ctypes.byref(_ctx)) -def _find_card(matches): - """Find the ALSA card number for the device.""" +def _find_card(matches, vid=None, pid=None, usbbus=None): + """ALSA card number for a device. + + Matched on /proc/asound/card*/usbid, which is the device's vid:pid, rather + than on names. Name matching was ambiguous the moment two Elgato devices + were connected: every profile's match list ends in "Elgato", so all three + resolved to whichever Elgato card came first, and OpenWave would read one + device over USB while driving the other's ALSA controls. + + usbbus ("bus/device") disambiguates two of the SAME model, where vid:pid + alone cannot. + """ + if vid is not None and pid is not None: + want = f"{vid:04x}:{pid:04x}" + for path in sorted(glob.glob("/proc/asound/card*/usbid")): + try: + with open(path) as f: + if f.read().strip().lower() != want: + continue + if usbbus is not None: + bus_path = os.path.join(os.path.dirname(path), "usbbus") + try: + with open(bus_path) as f: + if f.read().strip() != usbbus: + continue + except OSError: + pass + except OSError: + continue + digits = "".join(c for c in os.path.basename(os.path.dirname(path)) + if c.isdigit()) + if digits: + return digits + + # A vid:pid was given and /proc/asound was readable, so "no match" + # means the device is not present -- not that we should guess. Falling + # through to the name match here is what made an absent Wave:3 resolve + # to a connected Dock, because every match list ends in "Elgato". + if glob.glob("/proc/asound/card*/usbid"): + return None + + # Name matching only when /proc/asound is unreadable at all. try: r = subprocess.run(["aplay", "-l"], capture_output=True, text=True, timeout=3) for line in r.stdout.splitlines(): @@ -162,7 +204,9 @@ def connect(self): if handle: self._handle = handle self.profile = profile - self._card = _find_card(profile.card_match) + self._card = _find_card( + profile.card_match, vid=profile.vid, pid=profile.pid, + ) return raise RuntimeError("No supported Elgato Wave device found") From 99884ff787c1c88f95e75eb4c691646b5786ad17 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:54:18 -0500 Subject: [PATCH 32/99] Give every Elgato input its own row, named after the device A Wave XLR or an XLR Dock is the reason someone runs this, so its microphone should already be in the matrix rather than waiting to be added by hand. Each connected Elgato capture input now gets a row automatically, named after the device -- "Wave XLR", "XLR Dock" -- because with two connected there is no single "the microphone" to speak of. Elgato is identified by the USB vendor id behind the card rather than by matching "Elgato" in a string, and the display name is the ALSA description with the vendor prefix and channel-layout suffix trimmed. Offered once, not enforced: a node already proposed is recorded in the UI state, so a row the user deletes stays deleted rather than returning on the next launch. Rows for Elgato hardware are not deletable in the first place -- they are discovered from the device, so removing one would only reappear and read as a bug. A row added by hand for the same hardware is promoted to the same status, since what matters is the device behind it. The built-in row is no longer named from the USB profile. With two devices connected the profile that opened over USB and the capture node that row carries can be different hardware, and it was labelling the row after the wrong one; it takes its name from the node it actually carries. When there is no Wave device at all the row is removed rather than left dead -- the mixes, the application sources and any other capture device are pure PipeWire and work perfectly well without one. Also fixes the mute icon on capture rows, which was hardcoded to a speaker at construction and only became a microphone after the first toggle. --- wavexlr/app.py | 120 ++++++++++++++++++++++++++++++++++++++++--- wavexlr/mixer.py | 31 ++++++++++- wavexlr/mixmatrix.py | 5 +- wavexlr/sources.py | 21 ++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index ecfd76b..b61241c 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -14,7 +14,7 @@ from .device import WaveDevice from .meter import MeterMonitor from .mixer import ( - Mixer, list_output_sinks, default_sink_name, OUTPUT_AUTO, OUTPUT_NONE, + Mixer, ELGATO_VID, list_capture_sources as _list_captures, list_output_sinks, default_sink_name, OUTPUT_AUTO, OUTPUT_NONE, claim_streams, stream_matches, ) from .mixdialog import MixDialog @@ -73,6 +73,8 @@ def __init__(self, **kwargs): # One-shot re-read of the routing after a mix output change settles. self._output_refresh_id = None self._sources = sources_module.load_seeded() + self._offered_nodes = set( + self._load_ui_state().get("offered_capture_nodes") or []) self._mixes = mixes_module.load_seeded() self._build_ui() @@ -94,6 +96,9 @@ def __init__(self, **kwargs): self.meter = MeterMonitor() self._meter_targets = {} self._wire_matrix_cells() + self._drop_builtin_mic_row_if_absent() + self._name_builtin_mic_row() + self._autodiscover_elgato_inputs() self._refresh_mix_emptiness() self._start_meters() self._start_stream_poll() @@ -147,6 +152,8 @@ def _save_ui_state(self): "width": self.get_width(), "height": self.get_height(), "maximized": self.is_maximized(), + "offered_capture_nodes": sorted( + getattr(self, "_offered_nodes", set())), "gain_locked": bool( getattr(self, "gain_lock", None) and self.gain_lock.get_active() ), @@ -258,7 +265,7 @@ def _build_ui(self): name=source.get("name", source_id), icon_name=source.get("icon_name", "applications-multimedia-symbolic"), has_level=True, - removable=True, + removable=not sources_module.is_protected(source), editable=True, reorderable=True, is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, @@ -563,7 +570,11 @@ def _apply_profile(self, profile): self.mix_scale_row.set_visible(profile.has_monitor_mix) if profile.has_monitor_mix: self.mix_scale.get_adjustment().set_upper(profile.mix_max) - self.mic_source.set_name(profile.display_name) + # Deliberately not naming the row from the USB profile: with two + # Elgato devices connected the profile that opened over USB and the + # capture node this row carries can be different hardware, and a row + # labelled after the wrong one is worse than a generic label. + self._name_builtin_mic_row() self.status_label.set_label(f"OpenWave — {profile.display_name}") def _format_gain(self, raw): @@ -590,8 +601,9 @@ def _apply_state(self, state): if "monitor_mix" in state: self.mix_scale.set_value(state["monitor_mix"]) self.mix_label.set_label(f"{state['monitor_mix'] / 256:.0f}%") - self.mic_source.set_volume(state["gain_raw"] / self._gain_max) - self.mic_source.set_muted(state["mute"]) + if self.mic_source is not None: + self.mic_source.set_volume(state["gain_raw"] / self._gain_max) + self.mic_source.set_muted(state["mute"]) self._updating_ui = False def _on_usb_error(self, e): @@ -1047,7 +1059,7 @@ def _install_source(self, source): name=source["name"], icon_name=source["icon_name"], has_level=True, - removable=True, + removable=not sources_module.is_protected(source), editable=True, reorderable=True, is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, @@ -1060,6 +1072,102 @@ def _install_source(self, source): self._refresh_source_meter(source["id"]) self._refresh_mix_emptiness() + def _drop_builtin_mic_row_if_absent(self): + """Remove the built-in row when there is no Wave device behind it. + + OpenWave is useful without one: the mixes, the application sources and + any other capture device are pure PipeWire and work on their own. What + does not work is a row wired to a device that is not there -- its cells + route nothing and its meter never moves, which reads as broken rather + than as absent. + """ + if getattr(self.mixer, "mic", None): + return + if self.matrix.source("mic") is None: + return + self.matrix.remove_source("mic") + self.mic_source = None + + def _name_builtin_mic_row(self): + """Label the built-in row after the device it actually carries. + + "Microphone" is ambiguous the moment a second Elgato device is + connected -- and which device this row ends up on depends on which + capture node PipeWire lists first, so a generic label hides that + entirely. + """ + node = getattr(self.mixer, "mic", None) + if not node or self.mic_source is None: + return + for dev in _list_captures(): + if dev.get("name") == node: + label = dev.get("short_name") or dev.get("description") + if label: + self.mic_source.set_name(label) + return + + def _autodiscover_elgato_inputs(self): + """Give every Elgato capture input a row of its own, once. + + A Wave XLR or an XLR Dock is the reason someone runs this, so its + microphone should already be in the matrix rather than waiting to be + added by hand -- and with two devices connected there is no single + "the microphone" to speak of, which is why each is named after itself + rather than sharing one generic row. + + Offered once, not enforced: a node this has already proposed is + recorded, so a row the user deletes stays deleted instead of coming + back on the next launch. + """ + elgato_nodes = { + d["name"] for d in _list_captures() + if d.get("vendor_id") == ELGATO_VID and d.get("name") + } + # A row added before this flag existed, or one the user added by hand + # for the same hardware, is protected too -- what matters is the device + # behind it, not how the row got there. + promoted = False + for source in self._sources.values(): + if (sources_module.kind(source) == sources_module.KIND_DEVICE + and source.get("node_name") in elgato_nodes + and not source.get("protected")): + source["protected"] = True + promoted = True + if promoted: + sources_module.save(self._sources) + + bound = self._bound_capture_nodes() + added = [] + for dev in _list_captures(): + node = dev.get("name") + if dev.get("vendor_id") != ELGATO_VID: + continue + if not node or node in bound or node in self._offered_nodes: + continue + source = sources_module.new_device_source( + name=dev.get("short_name") or dev.get("description", node), + node_name=node, + icon_name="audio-input-microphone-symbolic", + ) + # Not deletable: it is discovered from the hardware, so removing it + # would only reappear on the next launch and read as a bug. + source["protected"] = True + added.append(source) + self._offered_nodes.add(node) + if not added: + return + for source in added: + self._install_source(source) + # Pinned above the user's own rows: these are the device the + # application exists for. + self._sources = sources_module.set_order( + self._sources, [s["id"] for s in added]) + self.matrix.reorder_sources(list(self._sources)) + for sid in self._sources: + self._wire_source_row(sid) + self._wire_matrix_cells() + self._save_ui_state() + def _wire_source_row(self, source_id): """Connect a source row's own level slider and mute. diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 4144056..45f1c0b 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -15,6 +15,7 @@ import logging import os import signal +import re import subprocess import threading import time @@ -37,6 +38,29 @@ _libc = None +ELGATO_VID = 0x0FD9 + + +def _alsa_card_vendor(card_index): + """USB vendor id behind an ALSA card, or None if it is not a USB card.""" + try: + with open(f"/proc/asound/card{int(card_index)}/usbid") as f: + return int(f.read().strip().split(":")[0], 16) + except (OSError, ValueError, TypeError): + return None + + +def friendly_device_name(description): + """Trim a capture device's description to something worth showing. + + ALSA reports "Elgato XLR Dock Mono"; the vendor and the channel layout are + noise in a mixer row that already sits under an Elgato heading. + """ + name = re.sub(r"^Elgato\s+", "", str(description or "").strip()) + name = re.sub(r"\s+(Mono|Stereo|Analog Stereo|Digital Stereo)$", "", name) + return name or str(description or "") + + SOURCE_SINK_PREFIX = "openwave_src_" @@ -260,10 +284,15 @@ def list_capture_sources(): priority = int(props.get("priority.session", 0)) except (TypeError, ValueError): priority = 0 + description = props.get("node.description") or name out.append({ "name": name, - "description": props.get("node.description") or name, + "description": description, "priority": priority, + # Trimmed for display, plus the vendor behind the card so an + # Elgato input can be recognised without matching on strings. + "short_name": friendly_device_name(description), + "vendor_id": _alsa_card_vendor(props.get("alsa.card")), }) out.sort(key=lambda source: source["description"].lower()) return out diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 22443bc..f436c67 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -678,7 +678,10 @@ def __init__(self, *, name, icon_name, has_level, removable=False, self._mute_btn = Gtk.ToggleButton(valign=Gtk.Align.CENTER) self._mute_btn.add_css_class("flat") self._mute_btn.add_css_class("circular") - self._mute_icon = Gtk.Image.new_from_icon_name("audio-volume-high-symbolic") + self._mute_icon = Gtk.Image.new_from_icon_name( + "audio-input-microphone-symbolic" if is_capture + else "audio-volume-high-symbolic" + ) self._mute_btn.set_child(self._mute_icon) self._mute_handler = self._mute_btn.connect("toggled", self._on_mute_toggled) inner.append(self._mute_btn) diff --git a/wavexlr/sources.py b/wavexlr/sources.py index 22fc912..cdd4765 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -172,6 +172,15 @@ def new_source(*, name, match_app_name, icon_name=DEFAULT_APP_ICON): } +def is_protected(source): + """True for a row the user should not be able to delete. + + An Elgato input is the device the application exists for; it is discovered + automatically and removing it would only make it come back confusing. + """ + return bool((source or {}).get("protected")) + + def new_device_source(*, name, node_name, icon_name=DEFAULT_DEVICE_ICON): """Return a fresh capture-device source bound to a PipeWire source node. @@ -202,6 +211,18 @@ def remove(sources, source_id): save(sources) return sources +def set_order(sources, order): + """Rebuild the mapping in `order`, keeping anything the order omits. + + Insertion order is row order, so this is how a row is pinned to the top. + """ + seen = [sid for sid in order if sid in sources] + rest = [sid for sid in sources if sid not in seen] + reordered = {sid: sources[sid] for sid in seen + rest} + save(reordered) + return reordered + + def reorder(sources, source_id, delta): """Move a source `delta` places in the list, and persist the new order. From 7a20a0567207befc8c93a677f4e1eaee128d80ad Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:56:14 -0500 Subject: [PATCH 33/99] Group sources so only one in a group is live at a time Two microphones on one speaker is a normal setup -- a main and a backup, or two positions -- and having both open at once gives comb filtering rather than redundancy. Sources sharing a group are now mutually exclusive: unmuting one mutes the others. Per-row rather than global, because a podcast is the case that matters. Two microphones on the host go in one group and switch between themselves; the guest's microphone sits in another group, or none, and is never touched. A single 'active microphone' setting cannot express that, which is why the shell script this replaces had to mute the other device outright and broke using it as a second source. The group is free text on the source dialog. Empty means ungrouped, which is the default and leaves behaviour exactly as before. --- tests/test_stores.py | 36 ++++++++++++++++++++++++++++ wavexlr/app.py | 53 ++++++++++++++++++++++++++++++++++------- wavexlr/sourcedialog.py | 28 ++++++++++++++++++---- wavexlr/sources.py | 17 +++++++++++++ 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/tests/test_stores.py b/tests/test_stores.py index 1cdb139..8a07d66 100644 --- a/tests/test_stores.py +++ b/tests/test_stores.py @@ -155,3 +155,39 @@ def test_an_unknown_id_is_ignored(self): if __name__ == "__main__": unittest.main() + + +class ExclusivityGroups(unittest.TestCase): + """Two microphones on one speaker want exactly one of them live. + + A second speaker's microphone is in a different group, or none, and must + be unaffected -- which is the whole reason this is per-row rather than a + single global "active microphone". + """ + + def test_a_source_without_a_group_has_none(self): + self.assertEqual(sources.group({}), "") + self.assertEqual(sources.group({"group": ""}), "") + self.assertEqual(sources.group({"group": None}), "") + + def test_whitespace_does_not_create_a_distinct_group(self): + self.assertEqual(sources.group({"group": " Host "}), "Host") + + def test_groups_lists_what_is_in_use(self): + store = { + "a": {"group": "Host"}, "b": {"group": "Host"}, + "c": {"group": "Guest"}, "d": {}, + } + self.assertEqual(sources.groups(store), ["Guest", "Host"]) + + def test_a_podcast_layout_separates_the_speakers(self): + # Two mics on the host, one on the guest: muting across the host's + # pair must never reach the guest. + store = { + "host_main": {"group": "Host"}, + "host_backup": {"group": "Host"}, + "guest": {"group": "Guest"}, + } + host = [k for k, v in store.items() if sources.group(v) == "Host"] + self.assertEqual(sorted(host), ["host_backup", "host_main"]) + self.assertNotIn("guest", host) diff --git a/wavexlr/app.py b/wavexlr/app.py index b61241c..25c72dd 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1035,21 +1035,29 @@ def _bound_capture_nodes(self): nodes.add(self.mixer.mic) return {node for node in nodes if node} - def _on_source_confirmed(self, _dialog, name, match_app_name, icon_name): - self._install_source(sources_module.new_source( + def _on_source_confirmed(self, _dialog, name, match_app_name, icon_name, + group=""): + source = sources_module.new_source( name=name, match_app_name=match_app_name, icon_name=icon_name, - )) + ) + if group: + source["group"] = group + self._install_source(source) - def _on_device_source_confirmed(self, _dialog, name, node_name, icon_name): + def _on_device_source_confirmed(self, _dialog, name, node_name, icon_name, + group=""): # Queue the re-snapshot before installing: the reconcile that # _install_source triggers refuses to wire a node the snapshot has not # seen, and the worker runs queued tasks in insertion order, so the # refresh lands first. Doing it synchronously would put a pw-dump on # the GTK thread in a click handler. self.mixer.request_capture_poll() - self._install_source(sources_module.new_device_source( + source = sources_module.new_device_source( name=name, node_name=node_name, icon_name=icon_name, - )) + ) + if group: + source["group"] = group + self._install_source(source) def _install_source(self, source): """Persist a new source of either kind, give it a row, and wire it up.""" @@ -1191,8 +1199,36 @@ def _on_source_level_changed(self, _cell, volume, source_id): def _on_source_mute_toggled(self, _cell, muted, source_id): self.mixer.set_source_level( source_id, self._sources.get(source_id, {}).get("level", 1.0), muted) + if not muted: + self._enforce_exclusive_group(source_id) sources_module.save(self._sources) + def _enforce_exclusive_group(self, active_id): + """Leave only one source in a group unmuted. + + Two microphones on one speaker is a normal setup -- a main and a + backup, or two positions -- and having both open at once gives comb + filtering rather than redundancy. A group makes switching between them + one click, while a second speaker's microphone sits in a different + group and is untouched. A global default-source switch cannot express + that; this is per-row. + """ + active_group = sources_module.group(self._sources.get(active_id, {})) + if not active_group: + return + for sid, source in self._sources.items(): + if sid == active_id: + continue + if sources_module.group(source) != active_group: + continue + if source.get("muted"): + continue + source["muted"] = True + self.mixer.set_source_level(sid, source.get("level", 1.0), True) + cell = self.matrix.source(sid) + if cell is not None: + cell.set_muted(True) + def _on_move_source_clicked(self, _matrix, source_id, delta): before = list(self._sources) self._sources = sources_module.reorder(self._sources, source_id, delta) @@ -1215,7 +1251,8 @@ def _on_edit_source_clicked(self, _matrix, source_id): dialog.connect("source-edited", self._on_source_edited) dialog.present(self) - def _on_source_edited(self, _dialog, source_id, name, binding, icon_name): + def _on_source_edited(self, _dialog, source_id, name, binding, icon_name, + group=""): if source_id not in self._sources: return # removed while the dialog was open source = self._sources[source_id] @@ -1229,7 +1266,7 @@ def _on_source_edited(self, _dialog, source_id, name, binding, icon_name): # sources_module.update, never new_source: the id is the prefix of every # "." cell key, so a fresh id would orphan the levels. - fields = {"name": name, "icon_name": icon_name} + fields = {"name": name, "icon_name": icon_name, "group": group} if not is_device: # A device's binding is its node_name, which the dialog shows but # does not offer to edit — it is picked from live hardware, and diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index 473a770..9a2cf2d 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -39,13 +39,15 @@ class AddSourceDialog(Adw.Dialog): __gsignals__ = { # (display_name, match_app_name, icon_name) - "source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str)), + # (display_name, match_app_name, icon_name, group) + "source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str, str)), # (display_name, capture_node_name, icon_name) - "device-source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str)), + # (display_name, capture_node_name, icon_name, group) + "device-source-confirmed": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str, str)), # (source_id, display_name, binding, icon_name). `binding` is the # match_app_name for an app source and "" for a device source, whose # node_name is hardware and is not editable here. - "source-edited": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str, str)), + "source-edited": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str, str, str)), } def __init__(self, source=None, *, exclude_nodes=()): @@ -254,6 +256,7 @@ def _on_device_confirm(self, _btn): self.emit( "device-source-confirmed", name, self._selected_device["name"], self._selected_icon, + self._group_text(), ) self.close() @@ -465,6 +468,16 @@ def _build_config_page(self, *, default_name=None, default_icon=None, dev_group.add(dev_row) # Icon picker + group_group = Adw.PreferencesGroup( + title="Group", + description="Sources sharing a group are mutually exclusive: " + "unmuting one mutes the others. Leave blank for none.", + ) + outer.append(group_group) + self._group_row = Adw.EntryRow(title="Group name") + self._group_row.set_text((self._source or {}).get("group", "") or "") + group_group.add(self._group_row) + icon_group = Adw.PreferencesGroup(title="Icon") outer.append(icon_group) @@ -598,6 +611,10 @@ def _remove_binding(self, name): self._bindings = [n for n in self._bindings if n != name] self._rebuild_bindings() + def _group_text(self): + row = getattr(self, "_group_row", None) + return row.get_text().strip() if row is not None else "" + def _on_icon_selected(self, flow): sel = flow.get_selected_children() if sel: @@ -621,7 +638,8 @@ def _on_confirm(self, _btn): # Carry the id so app.py routes this through sources.update() and # the row keeps its persisted per-mix levels. self.emit("source-edited", self._source["id"], name, app, - self._selected_icon) + self._selected_icon, self._group_text()) else: - self.emit("source-confirmed", name, app, self._selected_icon) + self.emit("source-confirmed", name, app, self._selected_icon, + self._group_text()) self.close() diff --git a/wavexlr/sources.py b/wavexlr/sources.py index cdd4765..a8abef4 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -172,6 +172,23 @@ def new_source(*, name, match_app_name, icon_name=DEFAULT_APP_ICON): } +def group(source): + """The exclusivity group a source belongs to, or "" for none. + + Sources sharing a group are mutually exclusive: unmuting one mutes the + others. Two microphones on one speaker -- a main and a backup -- want + exactly one of them live, while a second speaker's microphone is in a + different group (or none) and is unaffected. + """ + value = (source or {}).get("group") + return str(value).strip() if value else "" + + +def groups(sources): + """Every group name in use, for offering as suggestions.""" + return sorted({group(s) for s in sources.values() if group(s)}) + + def is_protected(source): """True for a row the user should not be able to delete. From a4af63dfd0f7d2d7f98e73e822c080a2ec36ca79 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 18:59:30 -0500 Subject: [PATCH 34/99] Pair the built-in row's microphone and headphones to one device find_wave_xlr_alsa took the first matching capture node and the first matching sink independently. With two Wave devices connected that paired the microphone of one with the headphone output of the other: on this machine the gain slider drove the XLR Dock while the headphone slider drove the Wave XLR, with nothing on screen to say so. Both halves now come from the same device, matched on the node-name stem, which carries the serial and so separates two of the same model. A card with no output still resolves, since an input-only profile is a normal configuration. That makes the built-in row's device change as cards come and go, which can leave a device source duplicating it -- two rows on one capture node route the same microphone twice into every mix. A device source matching the built-in row's node is dropped. The row's name also survives a rebuild now. reorder_sources reconstructs every row from the cached spec, so a name set on the widget alone was reverted by the next drag or by auto-discovery's reorder; it goes through the matrix, which updates the spec too. --- wavexlr/app.py | 27 +++++++++++++++++++++++- wavexlr/mixer.py | 49 +++++++++++++++++++++++++++++++++----------- wavexlr/mixmatrix.py | 20 ++++++++++++++++++ 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 25c72dd..420ba64 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1111,9 +1111,23 @@ def _name_builtin_mic_row(self): if dev.get("name") == node: label = dev.get("short_name") or dev.get("description") if label: - self.mic_source.set_name(label) + # Through the matrix, so the rebuilt row keeps the name: + # setting it on the cell alone is undone by any reorder. + self.matrix.set_source("mic", name=label) return + def _remove_source_row(self, source_id): + """Drop a source and its row, without a confirmation prompt. + + For rows OpenWave itself decides are redundant; the user-facing delete + path goes through _on_remove_source_clicked and its dialog. + """ + self.mixer.remove_source(source_id) + self._sources = sources_module.remove(self._sources, source_id) + self.matrix.remove_source(source_id) + self.meter.stop(source_id) + self._meter_targets.pop(source_id, None) + def _autodiscover_elgato_inputs(self): """Give every Elgato capture input a row of its own, once. @@ -1144,6 +1158,16 @@ def _autodiscover_elgato_inputs(self): if promoted: sources_module.save(self._sources) + # The built-in row can move between devices as cards come and go, so a + # device source may end up duplicating it. Two rows on one capture + # node route the same microphone twice into every mix. + mic_node = getattr(self.mixer, "mic", None) + for sid, source in list(self._sources.items()): + if (mic_node + and sources_module.kind(source) == sources_module.KIND_DEVICE + and source.get("node_name") == mic_node): + self._remove_source_row(sid) + bound = self._bound_capture_nodes() added = [] for dev in _list_captures(): @@ -1174,6 +1198,7 @@ def _autodiscover_elgato_inputs(self): for sid in self._sources: self._wire_source_row(sid) self._wire_matrix_cells() + self._name_builtin_mic_row() self._save_ui_state() def _wire_source_row(self, source_id): diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 45f1c0b..8655219 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -128,19 +128,44 @@ def _is_wave_card(node_name): return any(token in node_name for token in CARD_NAME_TOKENS) +def _node_device_stem(node_name): + """The device-identifying middle of an ALSA node name. + + alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9A40411NOP9-00.mono-fallback + -> usb-Elgato_Systems_Elgato_XLR_Dock_A8A9A40411NOP9-00 + + It carries the serial, so it distinguishes two devices of the same model. + """ + body = node_name.split(".", 1)[-1] + return body.rsplit(".", 1)[0] if "." in body else body + + def find_wave_xlr_alsa(): - """Return (mic_node_name, hp_node_name); either may be None if unplugged.""" - mic = next( - (p[1] for p in _pactl_short("sources") - if len(p) > 1 and p[1].startswith("alsa_input") and _is_wave_card(p[1])), - None, - ) - hp = next( - (p[1] for p in _pactl_short("sinks") - if len(p) > 1 and p[1].startswith("alsa_output") and _is_wave_card(p[1])), - None, - ) - return mic, hp + """Return (mic_node_name, hp_node_name) for ONE Wave device. + + Both halves must come from the same physical device. Picking the first + matching capture node and the first matching sink independently paired the + microphone of one device with the headphone output of another as soon as + two were connected -- so the gain slider drove one box and the headphone + slider another, with nothing to say so. + """ + captures = [p[1] for p in _pactl_short("sources") + if len(p) > 1 and p[1].startswith("alsa_input") + and _is_wave_card(p[1])] + sinks = {_node_device_stem(p[1]): p[1] for p in _pactl_short("sinks") + if len(p) > 1 and p[1].startswith("alsa_output") + and _is_wave_card(p[1])} + + # Prefer a device that offers both, so the two controls agree. + for capture in captures: + hp = sinks.get(_node_device_stem(capture)) + if hp: + return capture, hp + + # Otherwise take what exists: a card set to an input-only profile has a + # microphone and no output, which is a normal configuration. + return (captures[0] if captures else None, + next(iter(sinks.values()), None) if not captures else None) def _node_id_by_name(name, retries=20): diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index f436c67..fb55ed0 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -271,6 +271,26 @@ def _on_row_drop(self, _target, value, _x, _y, target_id): self.emit("move-source-clicked", dragged, delta) return True + def set_source(self, source_id, *, name=None, icon_name=None): + """Update a row's label, and the spec a rebuild restores it from. + + Setting it on the widget alone is not enough: reorder_sources tears + every row down and rebuilds it from _source_specs, so a name applied + only to the cell is silently reverted by the next drag. + """ + cell = self._sources.get(source_id) + spec = self._source_specs.get(source_id) + if name is not None: + if cell is not None: + cell.set_name(name) + if spec is not None: + spec["name"] = name + if icon_name is not None: + if cell is not None: + cell.set_icon(icon_name) + if spec is not None: + spec["icon_name"] = icon_name + def reorder_sources(self, order): """Redraw the source rows in `order`. From 544bec1beeb378b7d460a88c7b0113981aa3a591 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:00:29 -0500 Subject: [PATCH 35/99] Show which group a source is in on its row The group was only visible by opening a row's edit dialog, so a grouping nobody remembers setting is a grouping nobody knows they have -- and the whole point of it is that unmuting one row silences another, which is alarming without a visible reason. Each row now carries a small badge naming its group, with a tooltip saying what the group does. Ungrouped rows show nothing, which is the default. --- wavexlr/app.py | 7 +++---- wavexlr/mixmatrix.py | 22 ++++++++++++++++++++++ wavexlr/style.css | 7 +++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 420ba64..2ba7ccf 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1213,6 +1213,7 @@ def _wire_source_row(self, source_id): source = self._sources.get(source_id, {}) cell.set_volume(float(source.get("level", 1.0))) cell.set_muted(bool(source.get("muted", False))) + self.matrix.set_source_group(source_id, sources_module.group(source)) cell.connect("volume-changed", self._on_source_level_changed, source_id) cell.connect("mute-toggled", self._on_source_mute_toggled, source_id) @@ -1302,10 +1303,8 @@ def _on_source_edited(self, _dialog, source_id, name, binding, icon_name, source.pop("match_app_name", None) self._sources = sources_module.update(self._sources, source_id, **fields) - cell = self.matrix.source(source_id) - if cell is not None: - cell.set_name(name) - cell.set_icon(icon_name) + self.matrix.set_source(source_id, name=name, icon_name=icon_name) + self.matrix.set_source_group(source_id, sources_module.group(source)) if not is_device and binding != old_binding: # _refresh_app_meter early-returns when the cached target is still diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index fb55ed0..3b91ba7 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -271,6 +271,11 @@ def _on_row_drop(self, _target, value, _x, _y, target_id): self.emit("move-source-clicked", dragged, delta) return True + def set_source_group(self, source_id, group): + cell = self._sources.get(source_id) + if cell is not None and hasattr(cell, "set_group"): + cell.set_group(group) + def set_source(self, source_id, *, name=None, icon_name=None): """Update a row's label, and the spec a rebuild restores it from. @@ -681,6 +686,13 @@ def __init__(self, *, name, icon_name, has_level, removable=False, # controls beside it and renders as a bare ellipsis. self._name_lbl.set_width_chars(10) self._name_lbl.set_tooltip_text(name) + + # Group badge. A grouping that is only visible by opening each row's + # edit dialog is a grouping nobody knows they have. + self._group_lbl = Gtk.Label(label="", xalign=0, visible=False) + self._group_lbl.add_css_class("openwave-group-badge") + self._group_lbl.add_css_class("caption") + text.append(self._group_lbl) self._name_lbl.add_css_class("heading") text.append(self._name_lbl) @@ -761,6 +773,16 @@ def __init__(self, *, name, icon_name, has_level, removable=False, remove_btn.connect("clicked", lambda _: self.emit("remove-clicked")) inner.append(remove_btn) + def set_group(self, group): + """Show which exclusivity group this row is in, if any.""" + group = (group or "").strip() + self._group_lbl.set_label(f"\u2b24 {group}" if group else "") + self._group_lbl.set_visible(bool(group)) + self._group_lbl.set_tooltip_text( + f"Only one source in \u201c{group}\u201d is live at a time" + if group else None + ) + def set_name(self, name): self._name_lbl.set_label(name) self._name_lbl.set_tooltip_text(name) diff --git a/wavexlr/style.css b/wavexlr/style.css index 251aea2..f6a7f4d 100644 --- a/wavexlr/style.css +++ b/wavexlr/style.css @@ -56,3 +56,10 @@ label.openwave-muted { image.openwave-muted { color: @error_color; } + +/* Exclusivity group badge on a source row. */ +.openwave-group-badge { + color: @accent_color; + font-size: 0.75em; + opacity: 0.9; +} From 0bd60e65d0d79473b84330d69654283dd5c818b1 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:06:00 -0500 Subject: [PATCH 36/99] Group by dragging, switch with one press, and move the add buttons up Three changes to how a source row is handled. The add buttons sit above the grid at the top left. Below it they drifted down the window as rows were added and ended up off-screen on a full matrix, which is where they are needed most. Grouping is a drag, not a text field. Typing the same string into two dialogs and hoping they match is a poor way to express "these two are the same speaker". Dropping a row onto the middle of another groups them; dropping near the top or bottom edge still reorders. The drop target says which it will do before release -- a filled outline for grouping, an edge line for moving -- because one gesture with two outcomes is otherwise a guess. The target names the group, so dropping onto an ungrouped row starts one named after it. Grouped rows gain a switch button. Making a different microphone live was unmute-this-then-remember-to-mute-that; it is now one press, and the button shows which row is live and greys out on the one that already is. Joining a group joins its exclusivity immediately: whichever of the two was already live stays live and the other is muted, rather than leaving both open and letting the user discover the comb filtering. --- wavexlr/app.py | 36 +++++++++++++++++++ wavexlr/mixmatrix.py | 84 +++++++++++++++++++++++++++++++++++++------- wavexlr/style.css | 5 +++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 2ba7ccf..ce6ca18 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -276,6 +276,8 @@ def _build_ui(self): self.matrix.connect("remove-source-clicked", self._on_remove_source_clicked) self.matrix.connect("edit-source-clicked", self._on_edit_source_clicked) self.matrix.connect("move-source-clicked", self._on_move_source_clicked) + self.matrix.connect("switch-source-clicked", self._on_switch_source_clicked) + self.matrix.connect("group-sources-clicked", self._on_group_sources_clicked) self.matrix.connect("add-mix-clicked", self._on_add_mix_clicked) self.matrix.connect("rename-mix-clicked", self._on_rename_mix_clicked) self.matrix.connect("remove-mix-clicked", self._on_remove_mix_clicked) @@ -1229,6 +1231,40 @@ def _on_source_mute_toggled(self, _cell, muted, source_id): self._enforce_exclusive_group(source_id) sources_module.save(self._sources) + def _on_group_sources_clicked(self, _matrix, dragged_id, target_id): + """Put the dragged source in the target's group. + + The target names the group: dropping onto a row that has none starts + one named after it, so grouping is a single gesture rather than typing + the same string into two dialogs and hoping they match. + """ + dragged = self._sources.get(dragged_id) + target = self._sources.get(target_id) + if dragged is None or target is None: + return + group = sources_module.group(target) or target.get("name", target_id) + self._sources = sources_module.update(self._sources, target_id, group=group) + self._sources = sources_module.update(self._sources, dragged_id, group=group) + for sid in (target_id, dragged_id): + self.matrix.set_source_group(sid, group) + # Joining a group means joining its exclusivity: leave only the one + # that was already live unmuted. + live = target_id if not target.get("muted") else dragged_id + self._on_switch_source_clicked(None, live) + + def _on_switch_source_clicked(self, _matrix, source_id): + """Make one source the live one in its group, in a single press.""" + source = self._sources.get(source_id) + if source is None: + return + source["muted"] = False + self.mixer.set_source_level(source_id, source.get("level", 1.0), False) + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_muted(False) + self._enforce_exclusive_group(source_id) + sources_module.save(self._sources) + def _enforce_exclusive_group(self, active_id): """Leave only one source in a group unmuted. diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 3b91ba7..54172f5 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -34,6 +34,10 @@ class MixMatrix(Gtk.Box): "edit-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), # (source_id, delta) -- -1 to move a row up, +1 to move it down "move-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str, int)), + # Make this source the live one in its group + "switch-source-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + # (dragged_id, target_id) -- put the first in the second's group + "group-sources-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str, str)), "add-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "rename-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "remove-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), @@ -81,21 +85,22 @@ def __init__(self): corner.set_size_request(400, 64) self._grid.attach(corner, 0, 0, 1, 1) - # "+ Add Source" / "+ Add Mix" trailing affordances, below the grid. - # The mix button sits here rather than in a trailing grid column so - # that adding and removing columns never has to renumber it. + # "+ Add Source" / "+ Add Mix" sit above the grid, at the top left, + # rather than trailing below it: below, they moved down the window as + # rows were added and ended up off-screen on a full matrix. Neither is + # a grid column, so adding or removing one never renumbers them. add_row = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=6, - margin_start=12, margin_end=12, margin_bottom=12, + halign=Gtk.Align.START, + margin_start=12, margin_end=12, margin_top=12, margin_bottom=2, ) - wrapper.append(add_row) + wrapper.prepend(add_row) self._add_btn = Gtk.Button( label="+ Add Source", halign=Gtk.Align.START, ) self._add_btn.add_css_class("openwave-add-source") - self._add_btn.set_size_request(400, -1) self._add_btn.connect("clicked", lambda _: self.emit("add-source-clicked")) add_row.append(self._add_btn) @@ -104,7 +109,6 @@ def __init__(self): halign=Gtk.Align.START, ) self._add_mix_btn.add_css_class("openwave-add-mix") - self._add_mix_btn.set_size_request(220, -1) self._add_mix_btn.connect("clicked", lambda _: self.emit("add-mix-clicked")) add_row.append(self._add_mix_btn) @@ -204,6 +208,10 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, ) if reorderable: self._make_row_draggable(source, source_id) + source.connect( + "switch-clicked", + lambda _s, sid=source_id: self.emit("switch-source-clicked", sid), + ) source.connect( "move-clicked", lambda _s, delta, sid=source_id: self.emit("move-source-clicked", sid, delta), @@ -255,18 +263,45 @@ def _begin(_source, drag_obj, widget=cell): drop = Gtk.DropTarget.new(GObject.TYPE_STRING, Gdk.DragAction.MOVE) drop.connect("drop", self._on_row_drop, source_id) - drop.connect("enter", lambda _t, _x, _y, w=cell: - (w.add_css_class("openwave-drop-target"), Gdk.DragAction.MOVE)[1]) - drop.connect("leave", lambda _t, w=cell: w.remove_css_class("openwave-drop-target")) + drop.connect("motion", self._on_row_motion, source_id) + drop.connect("leave", lambda _t, w=cell: self._clear_drop_hint(w)) cell.add_controller(drop) - def _on_row_drop(self, _target, value, _x, _y, target_id): - dragged = str(value) + # Fraction of a row's height at each end that means "move here" rather + # than "group with this". The middle is the larger target because grouping + # is the deliberate act; reordering is the one you can repeat cheaply. + _EDGE_ZONE = 0.28 + + def _drop_is_grouping(self, cell, y): + height = cell.get_height() or 64 + return self._EDGE_ZONE * height <= y <= (1 - self._EDGE_ZONE) * height + + def _on_row_motion(self, target, _x, y, target_id): + """Show which of the two outcomes a release would produce.""" cell = self._sources.get(target_id) + if cell is None: + return Gdk.DragAction.MOVE + grouping = self._drop_is_grouping(cell, y) + cell.remove_css_class("openwave-drop-target") + cell.remove_css_class("openwave-drop-group") + cell.add_css_class( + "openwave-drop-group" if grouping else "openwave-drop-target") + return Gdk.DragAction.MOVE + + def _clear_drop_hint(self, cell): if cell is not None: cell.remove_css_class("openwave-drop-target") + cell.remove_css_class("openwave-drop-group") + + def _on_row_drop(self, _target, value, _x, y, target_id): + dragged = str(value) + cell = self._sources.get(target_id) + self._clear_drop_hint(cell) if dragged == target_id or dragged not in self._source_ids: return False + if cell is not None and self._drop_is_grouping(cell, y): + self.emit("group-sources-clicked", dragged, target_id) + return True delta = self._source_ids.index(target_id) - self._source_ids.index(dragged) self.emit("move-source-clicked", dragged, delta) return True @@ -635,6 +670,8 @@ class SourceCell(Gtk.Box): "remove-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), # (delta) -- -1 to move this row up, +1 to move it down "move-clicked": (GObject.SignalFlags.RUN_FIRST, None, (int,)), + # Make this the live source in its group + "switch-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "edit-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), } @@ -710,6 +747,20 @@ def __init__(self, *, name, icon_name, has_level, removable=False, self._mute_btn = Gtk.ToggleButton(valign=Gtk.Align.CENTER) self._mute_btn.add_css_class("flat") self._mute_btn.add_css_class("circular") + # Shown only on a grouped row: one press makes this the live source + # and silences its group-mates, rather than unmuting one and + # remembering to mute the other. + self._switch_btn = Gtk.Button( + valign=Gtk.Align.CENTER, visible=False, + tooltip_text="Switch to this source", + ) + self._switch_btn.add_css_class("flat") + self._switch_btn.add_css_class("circular") + self._switch_icon = Gtk.Image.new_from_icon_name("radio-symbolic") + self._switch_btn.set_child(self._switch_icon) + self._switch_btn.connect("clicked", lambda _b: self.emit("switch-clicked")) + inner.append(self._switch_btn) + self._mute_icon = Gtk.Image.new_from_icon_name( "audio-input-microphone-symbolic" if is_capture else "audio-volume-high-symbolic" @@ -776,6 +827,7 @@ def __init__(self, *, name, icon_name, has_level, removable=False, def set_group(self, group): """Show which exclusivity group this row is in, if any.""" group = (group or "").strip() + self._switch_btn.set_visible(bool(group)) self._group_lbl.set_label(f"\u2b24 {group}" if group else "") self._group_lbl.set_visible(bool(group)) self._group_lbl.set_tooltip_text( @@ -856,6 +908,14 @@ def _reflect_mute_icon(self, muted): else "audio-volume-high-symbolic") self._mute_icon.set_from_icon_name(icon) self._mute_btn.set_tooltip_text("Unmute" if muted else "Mute") + if getattr(self, "_switch_icon", None) is not None: + self._switch_icon.set_from_icon_name( + "radio-symbolic" if muted else "radio-checked-symbolic" + ) + self._switch_btn.set_sensitive(muted) + self._switch_btn.set_tooltip_text( + "Switch to this source" if muted else "This source is live" + ) # A muted row should be obvious at a glance down the column, not a # difference of one small icon. for widget in (self, self._name_lbl, self._mute_icon): diff --git a/wavexlr/style.css b/wavexlr/style.css index f6a7f4d..1ea7ba0 100644 --- a/wavexlr/style.css +++ b/wavexlr/style.css @@ -63,3 +63,8 @@ image.openwave-muted { font-size: 0.75em; opacity: 0.9; } + +/* Releasing here groups the dragged row with this one, rather than moving it. */ +.openwave-drop-group { + box-shadow: inset 0 0 0 2px @accent_bg_color; +} From 24ca513604cc3d43cdf4f292f96d63a6d22a601c Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:10:45 -0500 Subject: [PATCH 37/99] Retire the special microphone row The top row was hardcoded, and being hardcoded it could not be dragged, reordered or grouped -- so a Wave device's own microphone was the one input that could not join a group, which is the first thing anyone would want to group. Which device landed in that row also depended on which capture node PipeWire happened to list first, so unplugging one Wave device silently moved another into it. A Wave input is now discovered like any other Elgato input and is an ordinary device source: draggable, groupable, switchable, and still not deletable. The mixer no longer routes a "mic" pseudo-source, which would otherwise put the same microphone into every mix twice now that the device has a row of its own. Gain, mute and headphone volume are unaffected -- they were always the sidebar's job, driven over USB, and are a separate concern from whether the input appears in the matrix. What the row loses is a slider that drove hardware gain; the sidebar has that, with a lock. One-time migration: the hardcoded row covered an input that was therefore never offered a row of its own, and the row it did have was removed as a duplicate. Both are offered once more on the next launch, after which the usual rule -- a row you delete stays deleted -- applies again. --- wavexlr/app.py | 94 +++++++++++--------------------------------- wavexlr/mixer.py | 12 +++--- wavexlr/mixmatrix.py | 2 + 3 files changed, 30 insertions(+), 78 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index ce6ca18..8fa2063 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -73,8 +73,16 @@ def __init__(self, **kwargs): # One-shot re-read of the routing after a mix output change settles. self._output_refresh_id = None self._sources = sources_module.load_seeded() - self._offered_nodes = set( - self._load_ui_state().get("offered_capture_nodes") or []) + _ui = self._load_ui_state() + self._offered_nodes = set(_ui.get("offered_capture_nodes") or []) + if not _ui.get("builtin_row_retired"): + # The hardcoded microphone row used to cover one Elgato input, so + # that input was never offered a row of its own -- and the row it + # did have was removed as a duplicate. Offer them once more now + # that every input is an ordinary source; after this the normal + # "deleted stays deleted" rule applies. + self._offered_nodes.clear() + self._retire_builtin_row = True self._mixes = mixes_module.load_seeded() self._build_ui() @@ -96,8 +104,6 @@ def __init__(self, **kwargs): self.meter = MeterMonitor() self._meter_targets = {} self._wire_matrix_cells() - self._drop_builtin_mic_row_if_absent() - self._name_builtin_mic_row() self._autodiscover_elgato_inputs() self._refresh_mix_emptiness() self._start_meters() @@ -152,6 +158,7 @@ def _save_ui_state(self): "width": self.get_width(), "height": self.get_height(), "maximized": self.is_maximized(), + "builtin_row_retired": True, "offered_capture_nodes": sorted( getattr(self, "_offered_nodes", set())), "gain_locked": bool( @@ -249,14 +256,12 @@ def _build_ui(self): icon_name=mix.get("icon_name", mixes_module.DEFAULT_ICON), ) - self.mic_source = self.matrix.add_source( - "mic", name="Microphone", - icon_name="audio-input-microphone-symbolic", - has_level=True, - is_capture=True, - ) - self.mic_source.connect("volume-changed", self._on_mic_matrix_volume_changed) - self.mic_source.connect("mute-toggled", self._on_mic_matrix_mute_toggled) + # No hardcoded microphone row. A Wave device's input is discovered + # like any other Elgato input, which makes it an ordinary row: it can + # be dragged, reordered and grouped. The special row could do none of + # those, and which device landed in it depended on which capture node + # PipeWire happened to list first. + self.mic_source = None # User-defined app sources (persisted) for source_id, source in self._sources.items(): @@ -576,7 +581,6 @@ def _apply_profile(self, profile): # Elgato devices connected the profile that opened over USB and the # capture node this row carries can be different hardware, and a row # labelled after the wrong one is worse than a generic label. - self._name_builtin_mic_row() self.status_label.set_label(f"OpenWave — {profile.display_name}") def _format_gain(self, raw): @@ -603,9 +607,7 @@ def _apply_state(self, state): if "monitor_mix" in state: self.mix_scale.set_value(state["monitor_mix"]) self.mix_label.set_label(f"{state['monitor_mix'] / 256:.0f}%") - if self.mic_source is not None: - self.mic_source.set_volume(state["gain_raw"] / self._gain_max) - self.mic_source.set_muted(state["mute"]) + # Gain and mute live in the sidebar; no matrix row mirrors them. self._updating_ui = False def _on_usb_error(self, e): @@ -874,7 +876,7 @@ def _on_mic_matrix_mute_toggled(self, _source, muted): def _wire_matrix_cells(self): """Bind each per-cell slider/mute to the mixer + restore persisted levels.""" - source_ids = ["mic"] + list(self._sources.keys()) + source_ids = list(self._sources.keys()) for source_id in source_ids: for mix_id in self._mixes: self._wire_cell(source_id, mix_id) @@ -916,12 +918,7 @@ def _stream_poll_tick(self): return True def _start_meters(self): - """Begin metering the mic + any app source that already has a matching stream.""" - if self.mixer.mic: - self.meter.start( - "mic", self.mixer.mic, - lambda level: self._set_source_level("mic", level), - ) + """Meter every source that has something to meter.""" for source_id in self._sources.keys(): self._refresh_source_meter(source_id) @@ -1034,7 +1031,9 @@ def _bound_capture_nodes(self): for source in self._sources.values() if sources_module.kind(source) == sources_module.KIND_DEVICE } - nodes.add(self.mixer.mic) + # mixer.mic is deliberately NOT excluded: the Wave's own input gets a + # row like any other, and the device controls in the sidebar are a + # separate concern from whether it appears in the matrix. return {node for node in nodes if node} def _on_source_confirmed(self, _dialog, name, match_app_name, icon_name, @@ -1082,42 +1081,6 @@ def _install_source(self, source): self._refresh_source_meter(source["id"]) self._refresh_mix_emptiness() - def _drop_builtin_mic_row_if_absent(self): - """Remove the built-in row when there is no Wave device behind it. - - OpenWave is useful without one: the mixes, the application sources and - any other capture device are pure PipeWire and work on their own. What - does not work is a row wired to a device that is not there -- its cells - route nothing and its meter never moves, which reads as broken rather - than as absent. - """ - if getattr(self.mixer, "mic", None): - return - if self.matrix.source("mic") is None: - return - self.matrix.remove_source("mic") - self.mic_source = None - - def _name_builtin_mic_row(self): - """Label the built-in row after the device it actually carries. - - "Microphone" is ambiguous the moment a second Elgato device is - connected -- and which device this row ends up on depends on which - capture node PipeWire lists first, so a generic label hides that - entirely. - """ - node = getattr(self.mixer, "mic", None) - if not node or self.mic_source is None: - return - for dev in _list_captures(): - if dev.get("name") == node: - label = dev.get("short_name") or dev.get("description") - if label: - # Through the matrix, so the rebuilt row keeps the name: - # setting it on the cell alone is undone by any reorder. - self.matrix.set_source("mic", name=label) - return - def _remove_source_row(self, source_id): """Drop a source and its row, without a confirmation prompt. @@ -1160,16 +1123,6 @@ def _autodiscover_elgato_inputs(self): if promoted: sources_module.save(self._sources) - # The built-in row can move between devices as cards come and go, so a - # device source may end up duplicating it. Two rows on one capture - # node route the same microphone twice into every mix. - mic_node = getattr(self.mixer, "mic", None) - for sid, source in list(self._sources.items()): - if (mic_node - and sources_module.kind(source) == sources_module.KIND_DEVICE - and source.get("node_name") == mic_node): - self._remove_source_row(sid) - bound = self._bound_capture_nodes() added = [] for dev in _list_captures(): @@ -1200,7 +1153,6 @@ def _autodiscover_elgato_inputs(self): for sid in self._sources: self._wire_source_row(sid) self._wire_matrix_cells() - self._name_builtin_mic_row() self._save_ui_state() def _wire_source_row(self, source_id): diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 8655219..ca6bf85 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1198,7 +1198,10 @@ def _reconcile_all(self): # raise into _worker_loop's bare except, silently leaving a mix # unwired. with self._lock: - source_ids = ["mic"] + list(self._sources) + # No "mic" pseudo-source: a Wave device's input is an ordinary + # device source now, so routing it here as well would put the same + # microphone into every mix twice. + source_ids = list(self._sources) mix_ids = list(self._mixes) for source_id in source_ids: for mix_id in mix_ids: @@ -1208,11 +1211,6 @@ def _reconcile_cell(self, source_id, mix_id): state = self._state.get( f"{source_id}.{mix_id}", {"volume": 0.0, "muted": False} ) - if source_id == "mic": - self._reconcile_capture_cell( - source_id, mix_id, self.mic, state["volume"], state["muted"], - ) - return # Read without the lock, exactly as _reconcile_app_cell already does: # set_sources rebinds this dict rather than mutating it, so worker code # only ever sees a finished one. @@ -1268,7 +1266,7 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted if key not in self._procs: with self._lock: src_name = (self._sources.get(source_id) or {}).get( - "name", "Microphone" if source_id == "mic" else source_id) + "name", source_id) mix_name = (self._mixes.get(mix_id) or {}).get("name", mix_id) self._spawn_loopback( key, capture_node, mix_sink, node_name, diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 54172f5..46f4848 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -5,6 +5,8 @@ mix routing are placeholders until PipeWire mix-sink backend lands (v0.3.0). """ +import logging + import gi gi.require_version("Gtk", "4.0") From 24e7a218ada63f9fd69976745a0b4e918a8b6c5b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:13:40 -0500 Subject: [PATCH 38/99] Make the switch button an action, not a state readout It used a radio dot that filled in on the live row and greyed out there, so the control disabled itself exactly where a user would press it -- which reads as broken rather than as "already selected". Whether a source is live is already on the row: a muted one is red. Two opposing arrows now, and always clickable. Switching to the source that is already live is a no-op, which is a better outcome than a dead button. --- wavexlr/mixmatrix.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 46f4848..a392cf5 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -758,7 +758,11 @@ def __init__(self, *, name, icon_name, has_level, removable=False, ) self._switch_btn.add_css_class("flat") self._switch_btn.add_css_class("circular") - self._switch_icon = Gtk.Image.new_from_icon_name("radio-symbolic") + # Two opposing arrows rather than a radio dot: this is an action -- + # "make this one live" -- not a state to read. The state is already on + # the row, which is red when muted. + self._switch_icon = Gtk.Image.new_from_icon_name( + "mail-send-receive-symbolic") self._switch_btn.set_child(self._switch_icon) self._switch_btn.connect("clicked", lambda _b: self.emit("switch-clicked")) inner.append(self._switch_btn) @@ -910,13 +914,13 @@ def _reflect_mute_icon(self, muted): else "audio-volume-high-symbolic") self._mute_icon.set_from_icon_name(icon) self._mute_btn.set_tooltip_text("Unmute" if muted else "Mute") - if getattr(self, "_switch_icon", None) is not None: - self._switch_icon.set_from_icon_name( - "radio-symbolic" if muted else "radio-checked-symbolic" - ) - self._switch_btn.set_sensitive(muted) + if getattr(self, "_switch_btn", None) is not None: + # Deliberately always sensitive. A control that greys out exactly + # when you press it reads as broken, and switching to the source + # that is already live is harmless. self._switch_btn.set_tooltip_text( - "Switch to this source" if muted else "This source is live" + "Switch to this source" + if muted else "This source is already live" ) # A muted row should be obvious at a glance down the column, not a # difference of one small icon. From 58d4aa54728a202c29d9543328fef78237f35114 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:16:20 -0500 Subject: [PATCH 39/99] Let the swap button hand the group over from either row Pressing it on a muted row made that row live, but pressing it on the row that was already live did nothing -- so half the buttons in a group looked dead, which is the same complaint as greying it out, arrived at a different way. Two opposing arrows promise a swap, so it swaps: on the live row it hands over to the next source in the group, on a muted row it takes over. Two microphones therefore toggle from either end, which is the gesture someone reaches for mid-recording without looking. A group of one has nothing to hand to and stays a no-op, but its button is hidden anyway, since the control only appears on grouped rows. --- wavexlr/app.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 8fa2063..e799ca0 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1205,16 +1205,40 @@ def _on_group_sources_clicked(self, _matrix, dragged_id, target_id): self._on_switch_source_clicked(None, live) def _on_switch_source_clicked(self, _matrix, source_id): - """Make one source the live one in its group, in a single press.""" + """Hand the group over, in one press. + + On a muted row this makes that row live. On the row that is ALREADY + live it hands over to the next source in the group, so the button + swaps between two microphones from either end -- which is what two + opposing arrows promise, and what a control that did nothing on the + live row failed to deliver. + """ source = self._sources.get(source_id) if source is None: return - source["muted"] = False - self.mixer.set_source_level(source_id, source.get("level", 1.0), False) - cell = self.matrix.source(source_id) + + target_id = source_id + if not source.get("muted"): + group = sources_module.group(source) + members = [ + sid for sid, other in self._sources.items() + if sources_module.group(other) == group + ] if group else [] + if len(members) > 1: + nxt = (members.index(source_id) + 1) % len(members) + target_id = members[nxt] + else: + return # nothing to hand over to + + target = self._sources.get(target_id) + if target is None: + return + target["muted"] = False + self.mixer.set_source_level(target_id, target.get("level", 1.0), False) + cell = self.matrix.source(target_id) if cell is not None: cell.set_muted(False) - self._enforce_exclusive_group(source_id) + self._enforce_exclusive_group(target_id) sources_module.save(self._sources) def _enforce_exclusive_group(self, active_id): From dc6362b9cd0127e23c0dbad9b037ace6a1804937 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:20:45 -0500 Subject: [PATCH 40/99] Pin plumbing loopbacks to unity gain A mix reached its output through a loopback sitting at zero volume: the routing was perfect, every link in place, and nothing came out. WirePlumber remembers a volume per node NAME and restores it whenever that node reappears, so once such a node has been set to zero -- by hand, or by anything walking the graph -- it comes back silenced on every launch, with nothing on screen suggesting why. These nodes are not user controls. A mix's output loopback carries it to hardware and its capture-source loopback publishes it to applications; the volume people actually reach for is the mix's own, or a cell's. So both are forced to unity and unmuted when spawned. Verified by setting the output loopback to zero, restarting, and watching it come back at 1.00. --- wavexlr/mixer.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index ca6bf85..68b14e6 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1003,6 +1003,22 @@ def _do_start(self): self._started = True self._reconcile_all() + def _pin_unity(self, node_name): + """Force a plumbing node to unity gain, unmuted. + + These loopbacks carry a mix to hardware or publish it as a source; + neither is a user control, and the mix's own volume is what people + reach for. But WirePlumber remembers a volume per node NAME and + restores it whenever the node reappears, so a stray zero -- set by + hand, or by anything walking the graph -- silences that path on every + launch afterwards, with the routing looking perfectly correct. + """ + node_id = _node_id_by_name(node_name) + if node_id is None: + return + _wpctl("set-volume", node_id, "1.0") + _wpctl("set-mute", node_id, "0") + def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): """(Re)create one mix's output loopback for its current target.""" key = ("output", mix_id) @@ -1014,10 +1030,12 @@ def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): if target is None: return mix_name = (self._mixes.get(mix_id) or {}).get("name", mix_id) + node_name = f"openwave_loop_out_{mix_id}" self._spawn_loopback( - key, mix_sink, target, f"openwave_loop_out_{mix_id}", detach=True, + key, mix_sink, target, node_name, detach=True, description=f"{mix_name} \u2192 output", ) + self._pin_unity(node_name) def _mix_source_node(self, sink): """_source -- the name the hand-written config used, so an @@ -1085,6 +1103,7 @@ def _respawn_mix_sources(self): f'node.description="OpenWave {mix.get("name", mix_id)}" ' ), ) + self._pin_unity(node_name) else: # Already running: re-assert the link in case its sink was # replaced underneath it. pw-link is harmless when the link From 50344e1984892cc82e4b3695157cb4cb5f0089b8 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 19:49:46 -0500 Subject: [PATCH 41/99] Expose source level, mute and a state snapshot on the session bus switch-group let something outside the window pick which microphone is live, but not how loud anything is -- so a Stream Deck could swap microphones and then had to leave every fader alone. Three more actions close that. set-source-level and toggle-source-mute do what the row's own fader and mute button do, routed through the window so the one copy of the state stays the one copy: Mixer holds the same dict this holds and rewrites sources.json whole on every save, so a caller writing that file directly would be overwritten the next time a slider moved. The row's widgets are updated with their signals blocked, so the change lands once instead of bouncing back through the handler that caused it. Unmuting through the bus takes the group with it, exactly as unmuting the row does. A group is one live microphone however the unmute arrived; making that depend on which surface you touched would be a bug you could only find by having a deck. snapshot publishes name, level, mute, group and kind for every source as JSON on the action's state. One action rather than five, because a remote control draws all of it on one button and reading it as separate states would let them disagree mid-read; state rather than a return value, because Activate has no reply but Describe reads state and Changed fires when it moves. --- wavexlr/app.py | 186 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/wavexlr/app.py b/wavexlr/app.py index e799ca0..e7495f6 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1204,6 +1204,97 @@ def _on_group_sources_clicked(self, _matrix, dragged_id, target_id): live = target_id if not target.get("muted") else dragged_id self._on_switch_source_clicked(None, live) + def switch_group(self, group_name): + """Hand a named group over to its next source. Returns the new live id. + + The same operation the swap button performs, addressable by group name + so something outside the window -- a Stream Deck key -- can drive it + without knowing which source happens to be live. + """ + members = [ + sid for sid, source in self._sources.items() + if sources_module.group(source) == group_name + ] + if len(members) < 2: + return "" + live = next( + (sid for sid in members if not self._sources[sid].get("muted")), + None, + ) + # With nothing live, take the first; otherwise hand to the next along. + target = members[0] if live is None else members[ + (members.index(live) + 1) % len(members)] + self._on_switch_source_clicked(None, target) + return target + + def source_groups(self): + """Group names with more than one member, i.e. worth switching.""" + counts = {} + for source in self._sources.values(): + name = sources_module.group(source) + if name: + counts[name] = counts.get(name, 0) + 1 + return sorted(name for name, n in counts.items() if n > 1) + + def set_source_volume(self, source_id, level): + """Set a source's trim from outside the window. + + Routed through here rather than written to sources.json directly, + because Mixer holds the same dict and rewrites the file whole on every + save: an outside write would be discarded the next time a slider + moved. The row's own fader is updated with its signal blocked, so the + change lands once rather than bouncing back through the handler. + """ + source = self._sources.get(source_id) + if source is None: + return False + level = max(0.0, min(1.0, float(level))) + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_volume(level) + self.mixer.set_source_level(source_id, level, + source.get("muted", False)) + sources_module.save(self._sources) + return True + + def toggle_source_mute(self, source_id): + """Flip a source's mute. Returns the new state, or None if unknown. + + Unmuting a grouped source takes the group with it, exactly as + unmuting the row in the window does: a group is one live microphone, + however the unmute arrived. + """ + source = self._sources.get(source_id) + if source is None: + return None + muted = not source.get("muted", False) + source["muted"] = muted + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_muted(muted) + self.mixer.set_source_level(source_id, source.get("level", 1.0), muted) + if not muted: + self._enforce_exclusive_group(source_id) + sources_module.save(self._sources) + return muted + + def remote_snapshot(self): + """Everything a remote control needs to draw a button, as JSON.""" + return json.dumps({ + "sources": [ + { + "id": sid, + "name": source.get("name", sid), + "level": float(source.get("level", 1.0)), + "muted": bool(source.get("muted", False)), + "group": sources_module.group(source), + "kind": sources_module.kind(source), + } + for sid, source in self._sources.items() + ], + "groups": self.source_groups(), + }) + def _on_switch_source_clicked(self, _matrix, source_id): """Hand the group over, in one press. @@ -1396,6 +1487,101 @@ def __init__(self): "hide", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, "Start hidden in system tray", None, ) + self._register_remote_actions() + + def _register_remote_actions(self): + """Expose a few operations on the session bus. + + GApplication already exports org.gtk.Actions on com.github.openwave; + it simply had nothing registered. Adding actions here makes them + callable from outside with no IPC of our own -- which is what lets a + Stream Deck drive the parts of OpenWave that PipeWire cannot reach, + because the GUI owns the mixer state and the USB device. + + Handlers run on the GTK thread, like every other UI callback, so they + touch the same state by the same rules. + """ + switch = Gio.SimpleAction.new("switch-group", GLib.VariantType.new("s")) + switch.connect("activate", self._action_switch_group) + self.add_action(switch) + + groups = Gio.SimpleAction.new_stateful( + "source-groups", None, GLib.Variant("as", []), + ) + groups.connect("activate", self._action_refresh_groups) + self.add_action(groups) + + level = Gio.SimpleAction.new( + "set-source-level", GLib.VariantType.new("(sd)"), + ) + level.connect("activate", self._action_set_source_level) + self.add_action(level) + + mute = Gio.SimpleAction.new( + "toggle-source-mute", GLib.VariantType.new("s"), + ) + mute.connect("activate", self._action_toggle_source_mute) + self.add_action(mute) + + snapshot = Gio.SimpleAction.new_stateful( + "snapshot", None, GLib.Variant("s", "{}"), + ) + snapshot.connect("activate", self._action_refresh_snapshot) + self.add_action(snapshot) + + def _action_switch_group(self, _action, parameter): + if self._window is None or parameter is None: + return + try: + self._window.switch_group(parameter.get_string()) + except Exception: # noqa: BLE001 + logging.exception("switch-group failed") + + def _action_refresh_groups(self, action, _parameter): + """Publish the switchable group names as this action's state. + + State rather than a return value: org.gtk.Actions has no reply for + Activate, but it does expose state and emits Changed when it moves, so + a reader can both poll and subscribe. + """ + if self._window is None: + return + try: + action.set_state(GLib.Variant("as", self._window.source_groups())) + except Exception: # noqa: BLE001 + logging.exception("source-groups failed") + + def _action_set_source_level(self, _action, parameter): + if self._window is None or parameter is None: + return + source_id, level = parameter.unpack() + try: + self._window.set_source_volume(source_id, level) + except Exception: # noqa: BLE001 + logging.exception("set-source-level failed") + + def _action_toggle_source_mute(self, _action, parameter): + if self._window is None or parameter is None: + return + try: + self._window.toggle_source_mute(parameter.get_string()) + except Exception: # noqa: BLE001 + logging.exception("toggle-source-mute failed") + + def _action_refresh_snapshot(self, action, _parameter): + """Publish every source's name, level, mute and group as JSON. + + One action rather than one per field: a remote control needs the whole + picture to draw a button -- which microphone is live, how loud a source + is, whether it is muted -- and reading it as five separate states + would let them disagree with each other mid-read. + """ + if self._window is None: + return + try: + action.set_state(GLib.Variant("s", self._window.remote_snapshot())) + except Exception: # noqa: BLE001 + logging.exception("snapshot failed") def do_command_line(self, command_line): options = command_line.get_options_dict() From 6d63abef43a559c7b6d80ffe84b01b1de68a919b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 20:09:24 -0500 Subject: [PATCH 42/99] Document phantom power, groups and the remote-control surface The README still described the Wave XLR as gain, mute, headphones and low-Z, which has been wrong since phantom power was found at offset 6 -- and on the Dock, which has no front-panel button for it, the app is the only way to switch it at all. Microphone rows appearing by themselves, groups, the bundled default sources and the bus actions were all undocumented too. ARCHITECTURE gains the reasoning rather than the API listing: why exclusivity is per-group instead of a global default-source switch (two people at one table cannot be expressed by one), why grouping is a drop rather than a dialog (two rows cannot end up with group names differing by a typo), and why the remote actions route through the window instead of the config files (Mixer rewrites sources.json whole, and the firmware serves one process at a time). --- README.md | 69 ++++++++++++++++++++++++++++++++++++++++++-- docs/ARCHITECTURE.md | 49 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f7f5162..c1630bd 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,15 @@ Linux control application for **Elgato Wave** audio devices — the **Wave XLR** | Device | USB ID | Controls | |---|---|---| -| Wave XLR | `0fd9:007d` | Gain, mute, headphone volume, low impedance mode | +| Wave XLR | `0fd9:007d` | Gain, mute, headphone volume, low impedance mode, **48 V phantom power** | | Wave XLR MK.2 | `0fd9:00a6` | as the Wave XLR — it enumerates as "Elgato XLR Dock" and speaks the same vendor protocol | | Wave:3 | `0fd9:0070` | Gain, mute, headphone volume, monitor mix | +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 +48V indicator. The Dock has no front-panel button for it at +all, so on that hardware the app is the only way to switch it. + ## Features - **Mixing matrix** — user-defined mixes as columns, sources as rows. Each cell @@ -22,7 +27,23 @@ Linux control application for **Elgato Wave** audio devices — the **Wave XLR** - **Per-mix output** — every mix chooses its own output device, or none at all for a mix that exists only to be captured. A mix keeps playing when the window is closed. -- **Microphone controls** — Gain, mute (syncs with hardware button) +- **Microphone rows appear by themselves** — every Elgato capture input gets a + row named after its device ("XLR Dock", "Wave XLR"), so two interfaces + connected at once are told apart instead of contending for a single + "microphone" row. They cannot be deleted, only muted. +- **Microphone groups** — drag one microphone row onto another to group them. + Only one member of a group is live at a time and a single button hands the + group over to the next, which is what two microphones on one speaker + actually want; microphones in another group are untouched, so a second + speaker's microphone stays open. Two mics on one person and one on another + is two groups. +- **Sensible defaults** — System, Game, Music, Browser and Voice rows ship + pre-matched to the usual applications, with System as the catch-all. +- **Remote control** — mixes, source trims and microphone groups are drivable + from outside the window over the session bus. See + [Remote control](#remote-control). +- **Microphone controls** — Gain, mute (syncs with hardware button), 48 V + phantom power - **Headphone controls** — Volume (syncs with hardware knob), low impedance mode - **Hardware sync** — 10 Hz polling keeps the app in sync with physical controls - **System integration** — Mute and HP volume sync bidirectionally with PipeWire/ALSA @@ -36,6 +57,48 @@ Wave devices use USB Class control transfers on endpoint 0 for device configurat Both devices speak the same vendor protocol (`bRequest` 0x85 read / 0x05 write) but with different config layouts: the Wave XLR uses a 34-byte block (gain uint16 @0, mute @4, HP volume int16 Q8.8 @9, knob mode @14, low-Z @33), the Wave:3 a 16-byte block (gain uint16 Q8.8 dB @0, mute @4, HP volume int16 Q8.8 @7, monitor mix uint16 Q8.8 percent @10, dial mode @12 — 1=gain, 2=headphones, 3=mix). Per-model constants live in `wavexlr/profiles.py`; `python3 -m wavexlr.probe` (`dump` / `watch` / `poke`) verifies a device against its profile and helps map new fields. The device services vendor transfers from only one process at a time, so quit OpenWave before probing. +## Remote control + +OpenWave exports a small set of actions on the session bus, so a control +surface can drive the parts of it that PipeWire alone cannot reach — the +window owns the mixer state, and the GUI holds the only USB handle the +firmware will serve. + +There is no protocol of its own: `GApplication` already exports +`org.gtk.Actions` on `com.github.openwave`. + +```console +$ gdbus call --session --dest com.github.openwave \ + --object-path /com/github/openwave --method org.gtk.Actions.List +(['switch-group', 'set-source-level', 'toggle-source-mute', + 'source-groups', 'snapshot'],) +``` + +| Action | Parameter | Does | +|---|---|---| +| `switch-group` | `s` group name | Hands a microphone group to its next member | +| `set-source-level` | `(sd)` id, 0–1 | Sets a source's trim | +| `toggle-source-mute` | `s` id | Flips a source's mute, group rules included | +| `source-groups` | — | State: group names worth switching between | +| `snapshot` | — | State: every source's name, level, mute, group and kind, as JSON | + +The two read-only actions publish their answer as action *state* rather than +returning it: `Activate` has no reply, but `Describe` reads state and `Changed` +fires when it moves, so a reader can both poll and subscribe. Activate first to +refresh, then describe. + +`snapshot` is one action rather than one per field because a remote control +draws all of it on a single button, and reading it piecemeal would let the +parts disagree mid-read. + +Everything goes through the window rather than the config files. `Mixer` holds +the same dict the window holds and rewrites `sources.json` whole on every save, +so a caller writing that file directly is overwritten the next time a fader +moves. + +[**openwave-streamdeck**](https://github.com/NyleGarcia/openwave-streamdeck) is +a Stream Deck plugin built on this. + ## Install One-liner — detects Arch, Debian/Ubuntu, Fedora, openSUSE, or Void; installs deps and OpenWave: @@ -114,7 +177,7 @@ wavexlr/ mixer.py — The router: intake sinks, per-cell loopbacks, stream claiming mixes.py — Mix definitions store (~/.config/openwave/mixdefs.json) sources.py — Source definitions store (~/.config/openwave/sources.json) - mixmatrix.py — The sources x mixes grid widget + mixmatrix.py — The sources x mixes grid widget (drag to reorder or group) mixdialog.py — Create/rename a mix sourcedialog.py — Add or edit a source meter.py — Level metering via pw-cat diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 541505a..dd54926 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -172,3 +172,52 @@ restarts. Startup sweeps orphaned loopbacks by matching `openwave_`, which covers both the `openwave_loop_` cells and the mix capture sources named after their sink. + +## Groups + +A source row may name a group. Within one group exactly one row is unmuted; +unmuting any member mutes the rest, and `switch_group` hands the group to the +next member in row order. + +This is per-row rather than global on purpose. Two microphones on one speaker — +a main and a backup, or two positions — comb-filter when both are open, so +switching between them should be one gesture. A second speaker's microphone is +a different group and is untouched by that gesture. A global "default source" +switch cannot express two people at one table; two groups can. + +Grouping is a drop, not a dialog: dragging one row onto the middle of another +puts it in the target's group, starting one named after the target if it had +none. Dropping near an edge reorders instead. Two gestures, one control, and no +way for the two rows to end up holding group names that differ by a typo. + +## Remote control + +`GApplication` already exports `org.gtk.Actions` on `com.github.openwave`, so +letting something outside the window drive OpenWave needs no IPC of its own — +only actions registered on the application. `switch-group`, +`set-source-level` and `toggle-source-mute` do what the row's own controls do; +`source-groups` and `snapshot` publish state to read back. + +Everything routes through the window, never around it. Two reasons, and both +are structural rather than stylistic: + +- `Mixer` holds the same `sources` dict the window holds and rewrites + `sources.json` whole on every save. A caller that edited that file directly + would be silently overwritten the next time a fader moved. +- The firmware serves vendor transfers to one process at a time, and the GUI + holds the handle. Device gain cannot be set by anyone else while OpenWave is + open. + +The read-only actions publish their answer as action *state*, because +`org.gtk.Actions.Activate` has no reply. `Describe` returns state and `Changed` +fires when it moves, so a caller can poll or subscribe; the convention is to +activate first (which refreshes) and then describe. + +`snapshot` returns every source's name, level, mute, group and kind as one JSON +string. One action rather than one per field: a remote control draws all of it +on a single button, and five separate reads could catch the state mid-change +and disagree with each other. + +Sends and trims are deliberately absent from that surface. They are re-applied +on every reconcile, so a value set from outside would revert within a second — +an action that silently undoes itself is worse than one that is not offered. From cc71dd1e757d1669dd54728cc5a2bca4d76bca03 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 20:32:45 -0500 Subject: [PATCH 43/99] Add a screenshot of the matrix A mixing matrix is hard to describe and immediate to look at: rows, columns and one cell per pair explains in a glance what the routing section of the README spends four paragraphs on. The shot is also the clearest evidence for two of the features that are otherwise only claims -- a grouped pair of microphones with the muted one marked, and a mix routed nowhere that is still useful because it is published as a capture source. --- README.md | 8 ++++++++ docs/screenshot.png | Bin 0 -> 100975 bytes 2 files changed, 8 insertions(+) create mode 100644 docs/screenshot.png diff --git a/README.md b/README.md index c1630bd..e77e054 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Linux control application for **Elgato Wave** audio devices — the **Wave XLR** microphone interface and the **Wave:3** microphone. A reverse-engineered replacement for Elgato Wave Link, built with GTK4 + Adwaita. +![OpenWave](docs/screenshot.png) + +Sources are rows, mixes are columns, and each cell is how much of that source +the mix receives. 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 nowhere but still recordable. + ## Supported devices | Device | USB ID | Controls | diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..324b15af04b42d93532f7346a44d5631354012d7 GIT binary patch literal 100975 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

n%U-on^PC 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�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 Date: Sat, 29 Aug 2026 20:49:55 -0500 Subject: [PATCH 44/99] Cover running with no Elgato device attached A Wave XLR is the reason most people install this, but it is not a requirement: with a headset microphone and nothing else, OpenWave is still a mixer -- sources, mixes, per-mix outputs and application matching all work without a single vendor USB transfer. That was already true, and untested, which is the wrong way round for a property whose failure mode is silence rather than an error. A substring change to CARD_NAME_TOKENS wide enough to match a headset would pair the gain slider to a device it cannot drive, with nothing on screen to say so, and no existing test would have noticed. The positive case is asserted alongside the negative ones, since 'finds nothing' also passes when matching is broken outright. bare_mixer() gains the pending queue, so tests can call set_cell and the other real entry points and inspect what they persisted; with no worker running the work simply lands in _pending and stays there, spawning nothing. --- tests/support.py | 7 ++ tests/test_no_elgato.py | 149 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 tests/test_no_elgato.py diff --git a/tests/support.py b/tests/support.py index 4a84f39..0bfb4da 100644 --- a/tests/support.py +++ b/tests/support.py @@ -55,6 +55,13 @@ def bare_mixer(**attrs): mx.mic = None mx.hp = None mx._started = False + # set_cell and friends enqueue their reconcile even with no worker + # running. The queue is the seam: work lands in _pending and stays there, + # so a test can call the real entry points and inspect the state they + # persisted without a subprocess ever being spawned. + mx._pending = {} + mx._pending_lock = threading.Lock() + mx._wake = threading.Event() for key, value in attrs.items(): setattr(mx, key, value) return mx diff --git a/tests/test_no_elgato.py b/tests/test_no_elgato.py new file mode 100644 index 0000000..c76bf59 --- /dev/null +++ b/tests/test_no_elgato.py @@ -0,0 +1,149 @@ +"""OpenWave with no Elgato hardware attached at all. + +A Wave XLR is the reason most people install this, but it is not a +requirement: with a headset microphone and nothing else, OpenWave is still a +mixer -- sources, mixes, per-mix outputs and application matching all work +without a single vendor USB transfer. Nothing here may quietly assume a Wave +is present, because the failure that assumption produces is silence rather +than an error. +""" + +import unittest + +from wavexlr import mixer as mixer_mod +from wavexlr import sources as sources_module +from .support import bare_mixer, temp_config + +# A graph with a SteelSeries headset and OpenWave's own nodes -- and no Elgato +# card of any kind. +ARCTIS_SOURCES = [ + ["48", "alsa_input.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.mono-fallback"], + ["31", "openwave_personal_mix.monitor"], +] +ARCTIS_SINKS = [ + ["49", "alsa_output.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.iec958-stereo"], + ["31", "openwave_personal_mix"], +] + + +class NoWaveDevice(unittest.TestCase): + def setUp(self): + self._real = mixer_mod._pactl_short + mixer_mod._pactl_short = lambda kind: ( + ARCTIS_SOURCES if kind == "sources" else + ARCTIS_SINKS if kind == "sinks" else []) + + def tearDown(self): + mixer_mod._pactl_short = self._real + + def test_the_lookup_reports_nothing_rather_than_guessing(self): + """A headset is not a Wave, however much it looks like one to a + substring match: pairing the gain slider to it would drive the wrong + device with nothing on screen to say so.""" + self.assertEqual(mixer_mod.find_wave_xlr_alsa(), (None, None)) + + def test_a_headset_is_not_mistaken_for_a_wave_card(self): + for node in (s[1] for s in ARCTIS_SOURCES + ARCTIS_SINKS): + self.assertFalse(mixer_mod._is_wave_card(node), node) + + def test_a_wave_is_still_found_when_one_is_present(self): + """The negative tests above would also pass if matching were broken + outright, so the positive case is asserted alongside them.""" + dock = ("alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9-00" + ".mono-fallback") + dock_out = ("alsa_output.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9-00" + ".analog-stereo") + mixer_mod._pactl_short = lambda kind: ( + ARCTIS_SOURCES + [["50", dock]] if kind == "sources" else + ARCTIS_SINKS + [["51", dock_out]] if kind == "sinks" else []) + self.assertEqual(mixer_mod.find_wave_xlr_alsa(), (dock, dock_out)) + + +class RoutingWithoutAWave(unittest.TestCase): + """The matrix is the product; the Wave is one possible row in it.""" + + ARCTIS = "alsa_input.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.mono-fallback" + + def _mixer(self): + mx = bare_mixer() + mx._sources = { + "arctis": {"id": "arctis", "kind": "device", "name": "Arctis", + "node_name": self.ARCTIS, "level": 1.0, + "muted": False}, + "music": {"id": "music", "name": "Music", + "match_app_names": ["Spotify"], "level": 1.0, + "muted": False}, + } + mx._mixes = { + "personal": {"id": "personal", "name": "Personal Mix", + "sink": "openwave_personal_mix"}, + "chat": {"id": "chat", "name": "Chat Mix", + "sink": "openwave_chat_mix"}, + } + return mx + + def test_mic_and_hp_are_simply_absent(self): + mx = self._mixer() + self.assertIsNone(mx.mic) + self.assertIsNone(mx.hp) + + def test_a_headset_microphone_still_reaches_every_mix(self): + """The capture path is a loopback from the node to the mix sink, and + it does not care which vendor made the node.""" + mx = self._mixer() + mx.set_cell("arctis", "personal", 0.8, False) + mx.set_cell("arctis", "chat", 0.5, False) + self.assertAlmostEqual(mx.get_cell("arctis", "personal")["volume"], 0.8) + self.assertAlmostEqual(mx.get_cell("arctis", "chat")["volume"], 0.5) + + def test_application_sources_are_unaffected(self): + mx = self._mixer() + mx.set_cell("music", "personal", 0.55, False) + self.assertAlmostEqual(mx.get_cell("music", "personal")["volume"], 0.55) + + def test_a_trim_still_composes_with_a_send(self): + """Trim x send is the whole level model, and it is computed from the + source record -- there is no hardware in it.""" + mx = self._mixer() + mx.set_cell("arctis", "personal", 0.5, False) + mx._sources["arctis"]["level"] = 0.5 + self.assertAlmostEqual(mx._source_gain("arctis"), 0.5) + + def test_a_muted_source_contributes_nothing(self): + mx = self._mixer() + mx._sources["arctis"]["muted"] = True + self.assertEqual(mx._source_gain("arctis"), 0.0) + + +class DiscoveryWithoutElgato(unittest.TestCase): + def test_only_elgato_vendor_ids_are_auto_added(self): + """Auto-discovery is keyed on the USB vendor id, not on a name, so a + headset never acquires a row it cannot be removed from.""" + self.assertEqual(mixer_mod.ELGATO_VID, 0x0FD9) + self.assertNotEqual(0x1038, mixer_mod.ELGATO_VID) # SteelSeries + + def test_a_headset_row_stays_removable(self): + """Elgato rows are protected because deleting one would leave the + device unreachable; nothing else should inherit that.""" + arctis = {"id": "arctis", "kind": "device", "name": "Arctis"} + self.assertFalse(sources_module.is_protected(arctis)) + dock = {"id": "dock", "kind": "device", "name": "XLR Dock", + "protected": True} + self.assertTrue(sources_module.is_protected(dock)) + + def test_the_default_sources_need_no_hardware(self): + """System, Game, Music, Browser and Voice are application matches, so + a fresh install with no Elgato device is usable immediately.""" + defaults = sources_module.DEFAULT_SOURCES + self.assertTrue(defaults) + for source in defaults.values(): + self.assertNotIn("node_name", source) + + def test_a_fresh_install_seeds_those_sources_with_no_device(self): + with temp_config(): + seeded = sources_module.load_seeded() + self.assertEqual(set(seeded), set(sources_module.DEFAULT_SOURCES)) + + +if __name__ == "__main__": + unittest.main() From 1ecf51a9f2cfa1634f0d17f092c70358c580bd85 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sat, 29 Aug 2026 21:07:40 -0500 Subject: [PATCH 45/99] Expose per-cell sends on the session bus set-source-level moves a source in every mix at once, which is the wrong control for the thing the matrix exists to express: "quieter in chat, same in my ears" is a cell, not a trim. set-cell-level and toggle-cell-mute reach one. They route through the window like the rest, and here that is not a convention but the only thing that works: the mixer re-applies send x trim on every reconcile from the cell state, so a value poked into mixes.json is undone within a second. snapshot now carries the mixes and every cell, including the ones at zero. A caller cannot otherwise distinguish a send that is down from one that does not exist -- get_cell() returns the same default for both -- and a remote control has to draw the difference. --- README.md | 13 ++++--- docs/ARCHITECTURE.md | 13 +++++-- wavexlr/app.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e77e054..9bb68b3 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ There is no protocol of its own: `GApplication` already exports $ gdbus call --session --dest com.github.openwave \ --object-path /com/github/openwave --method org.gtk.Actions.List (['switch-group', 'set-source-level', 'toggle-source-mute', - 'source-groups', 'snapshot'],) + 'set-cell-level', 'toggle-cell-mute', 'source-groups', 'snapshot'],) ``` | Action | Parameter | Does | @@ -87,8 +87,10 @@ $ gdbus call --session --dest com.github.openwave \ | `switch-group` | `s` group name | Hands a microphone group to its next member | | `set-source-level` | `(sd)` id, 0–1 | Sets a source's trim | | `toggle-source-mute` | `s` id | Flips a source's mute, group rules included | +| `set-cell-level` | `(ssd)` source, mix, 0–1 | Sets one send — how much of a source a single mix receives | +| `toggle-cell-mute` | `(ss)` source, mix | Flips one cell's mute | | `source-groups` | — | State: group names worth switching between | -| `snapshot` | — | State: every source's name, level, mute, group and kind, as JSON | +| `snapshot` | — | State: every source, mix and cell, as JSON | The two read-only actions publish their answer as action *state* rather than returning it: `Activate` has no reply, but `Describe` reads state and `Changed` @@ -97,12 +99,15 @@ refresh, then describe. `snapshot` is one action rather than one per field because a remote control draws all of it on a single button, and reading it piecemeal would let the -parts disagree mid-read. +parts disagree mid-read. It reports **every** cell, including the ones at zero: +a caller cannot otherwise tell a send that is down from one that does not +exist. Everything goes through the window rather than the config files. `Mixer` holds the same dict the window holds and rewrites `sources.json` whole on every save, so a caller writing that file directly is overwritten the next time a fader -moves. +moves — and a cell written straight to `mixes.json` is undone even faster, +because `send × trim` is re-applied on every reconcile. [**openwave-streamdeck**](https://github.com/NyleGarcia/openwave-streamdeck) is a Stream Deck plugin built on this. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd54926..975619c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -218,6 +218,13 @@ string. One action rather than one per field: a remote control draws all of it on a single button, and five separate reads could catch the state mid-change and disagree with each other. -Sends and trims are deliberately absent from that surface. They are re-applied -on every reconcile, so a value set from outside would revert within a second — -an action that silently undoes itself is worse than one that is not offered. +Sends and trims are on that surface, but only because they go through the +window. They are re-applied on every reconcile from the source record and the +cell state, so a value poked into either config file from outside reverts +within a second. That is the whole reason there is a bus action for them +rather than a documented file format. + +Device gain is the exception that stays closed: the firmware serves vendor +transfers to one process at a time, and while the GUI is open it holds the +handle. No action can be offered that would work only when the window is +shut. diff --git a/wavexlr/app.py b/wavexlr/app.py index e7495f6..a9b37b8 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1278,6 +1278,38 @@ def toggle_source_mute(self, source_id): sources_module.save(self._sources) return muted + def set_cell_volume(self, source_id, mix_id, volume): + """Set how much of one source a single mix receives. + + The matrix cell, not the row trim: a send. Routed through the window + for the same reason everything else is -- the mixer re-applies + send x trim on every reconcile, so a value written anywhere else is + undone within a second. + """ + if source_id not in self._sources or mix_id not in self._mixes: + return False + volume = max(0.0, min(1.0, float(volume))) + current = self.mixer.get_cell(source_id, mix_id) + cell = self.matrix.cell(source_id, mix_id) + if cell is not None: + cell.set_volume(volume) + self.mixer.set_cell(source_id, mix_id, volume, current["muted"]) + self._refresh_mix_emptiness() + return True + + def toggle_cell_mute(self, source_id, mix_id): + """Flip one cell's mute. Returns the new state, or None if unknown.""" + if source_id not in self._sources or mix_id not in self._mixes: + return None + current = self.mixer.get_cell(source_id, mix_id) + muted = not current["muted"] + cell = self.matrix.cell(source_id, mix_id) + if cell is not None: + cell.set_muted(muted) + self.mixer.set_cell(source_id, mix_id, current["volume"], muted) + self._refresh_mix_emptiness() + return muted + def remote_snapshot(self): """Everything a remote control needs to draw a button, as JSON.""" return json.dumps({ @@ -1292,6 +1324,24 @@ def remote_snapshot(self): } for sid, source in self._sources.items() ], + "mixes": [ + {"id": mix_id, "name": mix.get("name", mix_id), + "sink": mix.get("sink", "")} + for mix_id, mix in self._mixes.items() + ], + # Every cell, not only the ones that are up. A remote control + # needs to draw a send that is currently at zero as readily as one + # that is not, and cannot tell the difference between "zero" and + # "absent" if the zeroes are left out. + "cells": { + f"{source_id}.{mix_id}": { + "volume": float(cell["volume"]), + "muted": bool(cell["muted"]), + } + for source_id in self._sources + for mix_id in self._mixes + for cell in (self.mixer.get_cell(source_id, mix_id),) + }, "groups": self.source_groups(), }) @@ -1523,6 +1573,18 @@ def _register_remote_actions(self): mute.connect("activate", self._action_toggle_source_mute) self.add_action(mute) + cell = Gio.SimpleAction.new( + "set-cell-level", GLib.VariantType.new("(ssd)"), + ) + cell.connect("activate", self._action_set_cell_level) + self.add_action(cell) + + cell_mute = Gio.SimpleAction.new( + "toggle-cell-mute", GLib.VariantType.new("(ss)"), + ) + cell_mute.connect("activate", self._action_toggle_cell_mute) + self.add_action(cell_mute) + snapshot = Gio.SimpleAction.new_stateful( "snapshot", None, GLib.Variant("s", "{}"), ) @@ -1568,6 +1630,24 @@ def _action_toggle_source_mute(self, _action, parameter): except Exception: # noqa: BLE001 logging.exception("toggle-source-mute failed") + def _action_set_cell_level(self, _action, parameter): + if self._window is None or parameter is None: + return + source_id, mix_id, volume = parameter.unpack() + try: + self._window.set_cell_volume(source_id, mix_id, volume) + except Exception: # noqa: BLE001 + logging.exception("set-cell-level failed") + + def _action_toggle_cell_mute(self, _action, parameter): + if self._window is None or parameter is None: + return + source_id, mix_id = parameter.unpack() + try: + self._window.toggle_cell_mute(source_id, mix_id) + except Exception: # noqa: BLE001 + logging.exception("toggle-cell-mute failed") + def _action_refresh_snapshot(self, action, _parameter): """Publish every source's name, level, mute and group as JSON. From 749812abc33dd206108c90c11abaa9eb3ff9ff14 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 09:14:29 -0500 Subject: [PATCH 46/99] Put OpenWave in the app drawer, and make starting at login a switch Three things were documented as file copies the user performs: a desktop entry into ~/.local/share/applications, an autostart entry into ~/.config/autostart, and --hide typed by hand. All three are now the app's job, because all three are user-level files needing no privileges -- which is also why none of them belongs in the first-run dialog that asks for a password. The drawer entry is rewritten when stale, not merely created. Its Exec line records where OpenWave was found, so an entry written from a checkout that has since been installed properly would otherwise keep launching a path that no longer exists, and the failure is a menu item that does nothing. Autostart carries an absolute command for the same reason a stale entry is a bug: a desktop file inherits no working directory, so "python3 -m wavexlr" resolves at login only if the checkout happens to be the session's cwd, which it never is. Installed, it uses the openwave on PATH; from a checkout it carries PYTHONPATH. An entry a desktop environment has disabled in place -- GNOME Tweaks does that rather than deleting the file -- reads back as off, so the switch cannot claim a login behaviour that will not happen. And the two files are independent: turning autostart off deletes only its own. Categories is one main category. AudioVideo plus Settings validates, but desktop-file-validate warns it may list the app twice in the menu, and a mixer belongs under Audio rather than system settings. Both files now pass desktop-file-validate with no warnings. --- README.md | 32 ++++++--- tests/test_desktop.py | 150 ++++++++++++++++++++++++++++++++++++++++++ wavexlr/app.py | 42 +++++++++++- wavexlr/desktop.py | 135 +++++++++++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 tests/test_desktop.py create mode 100644 wavexlr/desktop.py diff --git a/README.md b/README.md index 9bb68b3..6f97c36 100644 --- a/README.md +++ b/README.md @@ -162,19 +162,33 @@ OpenWave detects your init system at runtime: - **other** (macOS, Windows, no init detected) — the capture-fix section is disabled. -### Start hidden in tray -```bash -python3 -m wavexlr --hide -``` +### App drawer, starting at login, starting in the tray + +All three are handled by the app; none needs a file copied by hand. + +The **app drawer entry** is written on launch to +`~/.local/share/applications/openwave.desktop`, and rewritten if it goes +stale — the `Exec` line records where OpenWave was found, so an entry written +from a checkout that has since been installed properly would otherwise keep +launching a path that no longer exists. + +**Start at login** and **Start in the tray** are switches in the sidebar, +under *Startup*. They write `~/.config/autostart/openwave.desktop`, adding +`--hide` for the tray-only case. Turning autostart off deletes that file; +the drawer entry is a separate file and is left alone. + +Both are user-level files needing no privileges, which is why neither is part +of the first-run setup that asks for a password. An entry a desktop +environment has disabled in place (GNOME Tweaks does this rather than +deleting it) reads back as off, so the switch cannot claim a login behaviour +that will not happen. + +`--hide` still works on its own for a one-off: -### Start at login ```bash -cp /usr/share/openwave/openwave-autostart.desktop ~/.config/autostart/ +python3 -m wavexlr --hide ``` -### Desktop entry -Copy `wavexlr.desktop` to `~/.local/share/applications/` for app launcher integration. - ## Architecture ``` diff --git a/tests/test_desktop.py b/tests/test_desktop.py new file mode 100644 index 0000000..aa23eef --- /dev/null +++ b/tests/test_desktop.py @@ -0,0 +1,150 @@ +"""The app drawer entry and starting at login. + +Both are files written into the user's own directories, and both are silently +wrong in the same way: an Exec line that does not resolve produces an entry +that is present, looks right, and does nothing when clicked -- or worse, does +nothing at login, where nobody is watching. +""" + +import os +import tempfile +import unittest + +from wavexlr import desktop + + +class TempHome(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self._env = {k: os.environ.get(k) + for k in ("XDG_DATA_HOME", "XDG_CONFIG_HOME")} + os.environ["XDG_DATA_HOME"] = os.path.join(self._tmp.name, "data") + os.environ["XDG_CONFIG_HOME"] = os.path.join(self._tmp.name, "config") + + def tearDown(self): + for key, value in self._env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + self._tmp.cleanup() + + +class MenuEntry(TempHome): + def test_it_lands_where_the_drawer_looks(self): + desktop.ensure_menu_entry() + self.assertTrue(os.path.isfile(desktop.menu_entry_path())) + self.assertTrue( + desktop.menu_entry_path().endswith("/applications/openwave.desktop")) + + def test_it_is_a_valid_entry(self): + desktop.ensure_menu_entry() + body = open(desktop.menu_entry_path()).read() + self.assertTrue(body.startswith("[Desktop Entry]")) + for key in ("Type=Application", "Name=", "Exec=", "Icon=", + "Categories="): + self.assertIn(key, body) + + def test_it_groups_the_window_with_its_tray_icon(self): + """Without StartupWMClass the shell shows two entries for one app.""" + desktop.ensure_menu_entry() + self.assertIn("StartupWMClass=com.github.openwave", + open(desktop.menu_entry_path()).read()) + + def test_writing_it_twice_changes_nothing(self): + self.assertTrue(desktop.ensure_menu_entry()) + self.assertFalse(desktop.ensure_menu_entry()) + + def test_a_stale_entry_is_rewritten(self): + """An entry written from a checkout that has since been installed + would otherwise keep launching the path that no longer exists.""" + desktop.ensure_menu_entry() + path = desktop.menu_entry_path() + with open(path, "w") as handle: + handle.write("[Desktop Entry]\nExec=/gone/openwave\n") + self.assertTrue(desktop.ensure_menu_entry()) + self.assertIn(desktop.launch_command(), open(path).read()) + + +class LaunchCommand(unittest.TestCase): + def test_it_is_absolute(self): + """A desktop file inherits no working directory, so a relative + command resolves at login only by accident.""" + command = desktop.launch_command() + first = command.split()[0] + self.assertTrue(first.startswith("/") or first == "env", command) + + def test_a_checkout_carries_its_own_path(self): + import shutil + real = shutil.which + shutil.which = lambda _name: None + try: + command = desktop.launch_command() + finally: + shutil.which = real + self.assertIn("PYTHONPATH=", command) + self.assertIn("-m wavexlr", command) + checkout = command.split("PYTHONPATH=")[1].split()[0] + self.assertTrue(os.path.isdir(os.path.join(checkout, "wavexlr"))) + + +class Autostart(TempHome): + def test_off_by_default(self): + self.assertEqual(desktop.autostart_state(), (False, False)) + self.assertFalse(os.path.exists(desktop.autostart_path())) + + def test_turning_it_on_writes_the_file(self): + self.assertEqual(desktop.set_autostart(True), (True, False)) + self.assertTrue(os.path.isfile(desktop.autostart_path())) + self.assertEqual(desktop.autostart_state(), (True, False)) + + def test_turning_it_off_removes_it(self): + desktop.set_autostart(True) + desktop.set_autostart(False) + self.assertFalse(os.path.exists(desktop.autostart_path())) + self.assertEqual(desktop.autostart_state(), (False, False)) + + def test_turning_it_off_twice_is_not_an_error(self): + desktop.set_autostart(False) + desktop.set_autostart(False) + + def test_starting_hidden_passes_the_flag(self): + desktop.set_autostart(True, hidden=True) + self.assertIn("--hide", open(desktop.autostart_path()).read()) + self.assertEqual(desktop.autostart_state(), (True, True)) + + def test_the_flag_can_be_taken_away_again(self): + desktop.set_autostart(True, hidden=True) + desktop.set_autostart(True, hidden=False) + self.assertNotIn("--hide", open(desktop.autostart_path()).read()) + self.assertEqual(desktop.autostart_state(), (True, False)) + + def test_the_desktop_environment_is_told_it_is_enabled(self): + desktop.set_autostart(True) + self.assertIn("X-GNOME-Autostart-enabled=true", + open(desktop.autostart_path()).read()) + + def test_an_entry_disabled_by_the_desktop_reads_as_off(self): + """GNOME's own tweaks disable an entry in place rather than deleting + it, and a switch that ignored that would lie about the next login.""" + desktop.set_autostart(True) + path = desktop.autostart_path() + body = open(path).read().replace( + "X-GNOME-Autostart-enabled=true", + "X-GNOME-Autostart-enabled=false") + with open(path, "w") as handle: + handle.write(body) + self.assertEqual(desktop.autostart_state()[0], False) + + def test_autostart_and_the_menu_entry_are_separate_files(self): + """Removing one must never remove the other.""" + desktop.ensure_menu_entry() + desktop.set_autostart(True) + self.assertNotEqual(desktop.menu_entry_path(), + desktop.autostart_path()) + desktop.set_autostart(False) + self.assertTrue(os.path.isfile(desktop.menu_entry_path())) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index a9b37b8..862896f 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -20,7 +20,8 @@ from .mixdialog import MixDialog from .mixmatrix import MixMatrix from .sourcedialog import AddSourceDialog -from . import paths, setup, service, sources as sources_module, mixes as mixes_module +from . import (paths, setup, service, sources as sources_module, + mixes as mixes_module, desktop as desktop_module) logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") @@ -406,6 +407,30 @@ def _build_device_pane(self, parent): # Output routing is per mix and lives in each mix column's header # menu, not here — one device combo could only ever speak for one mix. + # --- Startup --- + startup_group = Adw.PreferencesGroup(title="Startup") + parent.append(startup_group) + + enabled, hidden = desktop_module.autostart_state() + self.autostart_row = Adw.SwitchRow( + title="Start at login", + subtitle="Keeps mixes routed before you open anything", + ) + self.autostart_row.set_active(enabled) + self._autostart_handler = self.autostart_row.connect( + "notify::active", self._on_autostart_toggled) + startup_group.add(self.autostart_row) + + self.tray_row = Adw.SwitchRow( + title="Start in the tray", + subtitle="No window on login; open it from the tray icon", + ) + self.tray_row.set_active(hidden) + # Only meaningful when something is starting it for you. + self.tray_row.set_sensitive(enabled) + self.tray_row.connect("notify::active", self._on_start_hidden_toggled) + startup_group.add(self.tray_row) + # --- Device info --- # Titleless group so the expander reads as a single collapsed line: it # is reference material, looked at once, and does not deserve a @@ -434,6 +459,20 @@ def _build_device_pane(self, parent): self.serial_row.add_suffix(self.serial_label) info_expander.add_row(self.serial_row) + def _on_autostart_toggled(self, row, _param): + enabled, _hidden = desktop_module.set_autostart( + row.get_active(), self.tray_row.get_active()) + self.tray_row.set_sensitive(enabled) + if enabled != row.get_active(): + # The file could not be written; show what is actually true + # rather than a switch that lies about the next login. + with GObject.signal_handler_block(row, self._autostart_handler): + row.set_active(enabled) + + def _on_start_hidden_toggled(self, row, _param): + if self.autostart_row.get_active(): + desktop_module.set_autostart(True, row.get_active()) + def _refresh_mix_emptiness(self): """Mark every mix that no source currently feeds.""" cells = self.mixer.cells() @@ -1676,6 +1715,7 @@ def do_activate(self): if setup.needs_setup(): self._show_setup_dialog() return + desktop_module.ensure_menu_entry() self._window = WaveXLRWindow(application=self) # Hide-to-tray on close instead of quitting self._window.connect("close-request", self._on_close_request) diff --git a/wavexlr/desktop.py b/wavexlr/desktop.py new file mode 100644 index 0000000..0b97cff --- /dev/null +++ b/wavexlr/desktop.py @@ -0,0 +1,135 @@ +"""Desktop integration: the app drawer entry and starting at login. + +Both are freedesktop .desktop files in the user's own directories, so neither +needs privileges and neither belongs in the first-run setup dialog that asks +for a password. The menu entry is written on every launch if it is missing or +stale; autostart is a choice, so it is only ever written when asked for. +""" + +import os +import shutil +import sys + +APP_ID = "openwave" +NAME = "OpenWave" +COMMENT = "Elgato Wave control for Linux" +ICON = "audio-input-microphone" +# One main category only. AudioVideo plus Settings validates, but +# desktop-file-validate warns it may list the app twice in the menu, +# and a mixer belongs under Audio rather than under system settings. +CATEGORIES = "AudioVideo;Audio;Mixer;" + + +def _data_home(): + return os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") + + +def _config_home(): + return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + + +def menu_entry_path(): + return os.path.join(_data_home(), "applications", f"{APP_ID}.desktop") + + +def autostart_path(): + return os.path.join(_config_home(), "autostart", f"{APP_ID}.desktop") + + +def launch_command(): + """How to start OpenWave again, from however it was started this time. + + `openwave` on PATH when there is one, because that survives the checkout + moving. Otherwise the running interpreter and the module, with an absolute + path: a desktop file has no working directory to inherit, so a bare + "python3 -m wavexlr" would only work when the checkout happens to be the + session's cwd, which it never is at login. + """ + installed = shutil.which(APP_ID) + if installed: + return installed + # PYTHONPATH rather than a flag or a Path= key: a desktop file inherits no + # working directory, so "python3 -m wavexlr" would only resolve when the + # checkout happened to be the session's cwd, which at login it never is. + checkout = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return f"env PYTHONPATH={checkout} {sys.executable} -m wavexlr" + + +def _render(exec_command, autostart=False): + lines = [ + "[Desktop Entry]", + "Type=Application", + f"Name={NAME}", + f"Comment={COMMENT}", + f"Exec={exec_command}", + f"Icon={ICON}", + f"Categories={CATEGORIES}", + "Terminal=false", + # Without this the tray icon and the window are two entries in the + # dock, because the shell has no way to tell they are one app. + f"StartupWMClass=com.github.openwave", + "X-GNOME-UsesNotifications=true", + ] + if autostart: + # Honoured by GNOME and KDE; ignored elsewhere, where the file simply + # being present is what enables it. + lines.append("X-GNOME-Autostart-enabled=true") + return "\n".join(lines) + "\n" + + +def _write(path, contents): + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w") as handle: + handle.write(contents) + os.replace(tmp, path) + + +def ensure_menu_entry(): + """Put OpenWave in the app drawer, rewriting a stale entry. + + Rewritten rather than only created, because the Exec line embeds where + OpenWave was found: an entry written from a checkout that has since been + installed properly would otherwise keep launching the old path forever. + """ + path = menu_entry_path() + wanted = _render(launch_command()) + try: + if os.path.exists(path) and open(path).read() == wanted: + return False + _write(path, wanted) + except OSError: + return False + return True + + +def autostart_state(): + """(enabled, hidden) for starting at login.""" + path = autostart_path() + try: + contents = open(path).read() + except OSError: + return False, False + enabled = "X-GNOME-Autostart-enabled=false" not in contents + hidden = False + for line in contents.splitlines(): + if line.startswith("Exec="): + hidden = "--hide" in line + return enabled, hidden + + +def set_autostart(enabled, hidden=False): + """Turn starting at login on or off. Returns the new (enabled, hidden).""" + path = autostart_path() + if not enabled: + try: + os.remove(path) + except OSError: + pass + return False, hidden + command = launch_command() + (" --hide" if hidden else "") + try: + _write(path, _render(command, autostart=True)) + except OSError: + return autostart_state() + return True, hidden From 302ee8f23db0f9e1a7f09a31626b967e565fcd1a Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 09:27:18 -0500 Subject: [PATCH 47/99] Reopen a capture device that enumerated but never started A Wave replugged while the system is running comes back on the bus, gets its ALSA card, gets a PipeWire node, and reports itself unmuted at full gain with phantom power on -- while delivering no audio frames at all. I hit this on an XLR Dock and checked every layer: node name matched, links were correct, ALSA capture switch on and gain at maximum, firmware config block reporting mute=0x00 and phantom=0x01. Everything said healthy. The microphone was dead. What separates it from a quiet room is that the device produces no data rather than zero-valued data: a live analogue input always has a noise floor. Measured against a working microphone, the difference was total -- the Arctis gave peak=93 across 31169 non-zero samples, the Dock gave 0 samples. So the signal is a meter that has received no bytes at all, not a meter reading a low level, which a muted microphone legitimately does. The remedy is to make ALSA close and reopen the card, which cycling its profile through "off" and back does; the profile is restored afterwards because OpenWave deliberately puts a Wave into an input-only one and coming back on another would silently change the device. Restarting the capture keepalive does not help, which I confirmed before writing this: it exists to prevent the race and cannot clear one that has already happened. Three refusals, each one a way this could do harm. An absent node is not stalled -- cycling a card for a device somebody just unplugged fights them, and I found that case by trying to reproduce the stall by setting a card off, which produces absence rather than a stall. Silence from a dead meter subprocess says something about pw-cat and nothing about the hardware, so silent_for reports nothing measured rather than a long silence. And it stops after two attempts: a card cycle is disruptive, and a genuinely broken device should be left to be noticed rather than reopened every minute forever. Unplugging clears that budget, because replugging is how the stall arises. The decision is a separate object from the acting on it, so every branch is testable without a sound card: its inputs are a name, a bool and two numbers. --- README.md | 35 +++++++- tests/test_recovery.py | 197 +++++++++++++++++++++++++++++++++++++++++ wavexlr/app.py | 36 +++++++- wavexlr/meter.py | 31 +++++++ wavexlr/mixer.py | 8 ++ wavexlr/recovery.py | 151 +++++++++++++++++++++++++++++++ 6 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 tests/test_recovery.py create mode 100644 wavexlr/recovery.py diff --git a/README.md b/README.md index 6f97c36..d911ce2 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,12 @@ all, so on that hardware the app is the only way to switch it. - **Headphone controls** — Volume (syncs with hardware knob), low impedance mode - **Hardware sync** — 10 Hz polling keeps the app in sync with physical controls - **System integration** — Mute and HP volume sync bidirectionally with PipeWire/ALSA -- **Audio capture fix** — Background daemon (systemd or runit) prevents the firmware race condition where mic goes silent +- **Audio capture fix** — a background daemon (systemd or runit) prevents the + firmware race where the microphone goes silent, and OpenWave itself + **reopens a capture device that has stalled**: replugged while the system is + running, a Wave comes back reporting itself unmuted at full gain with + phantom on, and delivers no audio frames at all. Every layer says it is + healthy, so nothing notices. See [Stalled capture](#stalled-capture). - **System tray** — Runs in background with tray icon, mute from tray menu - **First-run setup** — Configures udev permissions and audio service automatically @@ -65,6 +70,34 @@ Wave devices use USB Class control transfers on endpoint 0 for device configurat Both devices speak the same vendor protocol (`bRequest` 0x85 read / 0x05 write) but with different config layouts: the Wave XLR uses a 34-byte block (gain uint16 @0, mute @4, HP volume int16 Q8.8 @9, knob mode @14, low-Z @33), the Wave:3 a 16-byte block (gain uint16 Q8.8 dB @0, mute @4, HP volume int16 Q8.8 @7, monitor mix uint16 Q8.8 percent @10, dial mode @12 — 1=gain, 2=headphones, 3=mix). Per-model constants live in `wavexlr/profiles.py`; `python3 -m wavexlr.probe` (`dump` / `watch` / `poke`) verifies a device against its profile and helps map new fields. The device services vendor transfers from only one process at a time, so quit OpenWave before probing. +## Stalled capture + +A Wave replugged while the system is running enumerates, gets its ALSA card +and its PipeWire node, reports itself unmuted at full gain with phantom power +on — and produces nothing. Not quiet audio: no frames. + +The distinction that makes it detectable is **silence versus no data**. A live +analogue input always delivers a noise floor; a stalled one delivers nothing, +so a meter reading it blocks forever on its first read. That is the signal +OpenWave watches, and it is why a level threshold would be the wrong test — a +muted microphone in a quiet room is legitimately near zero and must not be +"recovered". + +The remedy is to make ALSA close and reopen the device, which cycling the +card's profile through `off` and back does. Restarting the capture keepalive +does not: it exists to *prevent* the race and cannot clear one that has +already happened. + +Three things it deliberately will not do. It will not act on a device that is +simply absent — unplugged is not broken, and cycling a card for a device +someone has just removed fights the person who removed it. It will not act on +silence reported by a dead meter subprocess, whose silence says something +about `pw-cat` and nothing about the hardware. And it gives up after two +attempts, because cycling a card is disruptive and a device that is genuinely +broken should be left alone to be noticed rather than reopened every minute +forever. Unplugging resets that budget, since replugging is how the stall +arises in the first place. + ## Remote control OpenWave exports a small set of actions on the session bus, so a control diff --git a/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..5ffdd86 --- /dev/null +++ b/tests/test_recovery.py @@ -0,0 +1,197 @@ +"""Recovering a capture device that enumerated but never started. + +The decision is separated from the act so it can be tested without a sound +card. What makes this worth testing is that both mistakes are silent: failing +to recover leaves a dead microphone that every layer reports as healthy, and +recovering too eagerly cycles a card underneath someone who is using it. +""" + +import unittest + +from wavexlr import recovery + +DOCK = ("alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9A40411NOP9-00" + ".mono-fallback") + + +class CardNames(unittest.TestCase): + def test_a_capture_node_names_its_card(self): + self.assertEqual( + recovery.card_name_for(DOCK), + "alsa_card.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9A40411NOP9-00") + + def test_the_profile_is_not_part_of_the_device(self): + """Two profiles of one card must resolve to the same card, or the + remedy would be aimed at a card that does not exist.""" + analog = ("alsa_output.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9-00" + ".analog-stereo") + mono = ("alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9-00" + ".mono-fallback") + self.assertEqual(recovery.card_name_for(analog), + recovery.card_name_for(mono)) + + def test_nodes_that_are_not_alsa_have_no_card(self): + for name in ("openwave_personal_mix", "openwave_src_music", + "spotify", "", None): + self.assertIsNone(recovery.card_name_for(name), name) + + +class Deciding(unittest.TestCase): + def setUp(self): + self.watch = recovery.StallWatch( + stall_seconds=8.0, cooldown_seconds=60.0, max_attempts=2) + + def test_a_live_node_delivering_nothing_is_recovered(self): + self.assertTrue( + self.watch.should_recover(DOCK, True, silent_for=9.0, now=100.0)) + + def test_a_node_delivering_recently_is_left_alone(self): + self.assertFalse( + self.watch.should_recover(DOCK, True, silent_for=1.0, now=100.0)) + + def test_an_absent_node_is_not_stalled(self): + """Unplugged is not broken. Cycling the card for a device someone has + just removed fights the person who removed it.""" + self.assertFalse( + self.watch.should_recover(DOCK, False, silent_for=999.0, + now=100.0)) + + def test_a_source_with_no_meter_is_not_stalled(self): + """silent_for is None when nothing is metering it, which is not the + same as a meter that is receiving nothing.""" + self.assertFalse( + self.watch.should_recover(DOCK, True, silent_for=None, now=100.0)) + + def test_the_remedy_is_not_repeated_immediately(self): + """Cycling a card is disruptive; a stall that survives one attempt + must not become a loop.""" + self.assertTrue(self.watch.should_recover(DOCK, True, 9.0, 100.0)) + self.watch.record_attempt(DOCK, 100.0) + self.assertFalse(self.watch.should_recover(DOCK, True, 9.0, 110.0)) + + def test_it_may_be_retried_after_the_cooldown(self): + self.watch.record_attempt(DOCK, 100.0) + self.assertTrue(self.watch.should_recover(DOCK, True, 9.0, 200.0)) + + def test_a_device_that_will_not_come_back_is_given_up_on(self): + """Two attempts, then left alone to be noticed rather than cycled + every minute forever.""" + for attempt, now in enumerate((100.0, 200.0)): + self.assertTrue( + self.watch.should_recover(DOCK, True, 9.0, now), attempt) + self.watch.record_attempt(DOCK, now) + self.assertFalse(self.watch.should_recover(DOCK, True, 9.0, 300.0)) + + def test_replugging_restores_the_budget(self): + """Unplugging and replugging is how the stall arises in the first + place, so it must not inherit the previous appearance's attempts.""" + for now in (100.0, 200.0): + self.watch.record_attempt(DOCK, now) + self.assertFalse(self.watch.should_recover(DOCK, True, 9.0, 300.0)) + self.watch.should_recover(DOCK, False, 9.0, 310.0) + self.watch.forget(DOCK) + self.assertTrue(self.watch.should_recover(DOCK, True, 9.0, 320.0)) + + def test_audio_returning_clears_the_count(self): + self.watch.record_attempt(DOCK, 100.0) + self.watch.record_recovered(DOCK) + self.assertTrue(self.watch.should_recover(DOCK, True, 9.0, 105.0)) + + def test_two_devices_are_counted_separately(self): + other = "alsa_input.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.mono-fallback" + for now in (100.0, 200.0): + self.watch.record_attempt(DOCK, now) + self.assertFalse(self.watch.should_recover(DOCK, True, 9.0, 300.0)) + self.assertTrue(self.watch.should_recover(other, True, 9.0, 300.0)) + + +class Cycling(unittest.TestCase): + def setUp(self): + self.calls = [] + self._real = recovery._pactl + self.profile = "input:mono-fallback" + + def fake(*args, timeout=5): + self.calls.append(args) + if args[0] == "list": + return (f"Card #3\n\tName: alsa_card.other\n" + f"\tActive Profile: off\n" + f"Card #4\n\tName: alsa_card.dock\n" + f"\tActive Profile: {self.profile}\n") + return "" + recovery._pactl = fake + + def tearDown(self): + recovery._pactl = self._real + + def test_it_reads_the_right_card(self): + """Two cards in the listing; picking the wrong one would restore a + profile that belongs to another device.""" + self.assertEqual(recovery.active_profile("alsa_card.dock"), + "input:mono-fallback") + self.assertEqual(recovery.active_profile("alsa_card.other"), "off") + self.assertIsNone(recovery.active_profile("alsa_card.absent")) + + def test_it_goes_through_off_and_back(self): + """Setting a card to the profile it already has is a no-op, and the + close-and-reopen is the entire point.""" + self.assertTrue(recovery.cycle_card("alsa_card.dock")) + sets = [c for c in self.calls if c[0] == "set-card-profile"] + self.assertEqual( + sets, + [("set-card-profile", "alsa_card.dock", "off"), + ("set-card-profile", "alsa_card.dock", "input:mono-fallback")]) + + def test_the_users_profile_is_what_comes_back(self): + """OpenWave deliberately puts a Wave into an input-only profile; + returning on a different one would silently change the device.""" + self.profile = "input:mono-fallback" + recovery.cycle_card("alsa_card.dock") + self.assertEqual(self.calls[-1][2], "input:mono-fallback") + + def test_a_card_already_off_is_left_alone(self): + self.assertFalse(recovery.cycle_card("alsa_card.other")) + self.assertFalse([c for c in self.calls if c[0] == "set-card-profile"]) + + def test_an_unknown_card_is_not_touched(self): + self.assertFalse(recovery.cycle_card("alsa_card.absent")) + self.assertFalse([c for c in self.calls if c[0] == "set-card-profile"]) + + +if __name__ == "__main__": + unittest.main() + + +class MeterSilence(unittest.TestCase): + """`silent_for` is the input the whole decision rests on.""" + + def setUp(self): + from wavexlr.meter import MeterMonitor + self.meter = MeterMonitor() + + def test_no_meter_reads_as_nothing_measured(self): + self.assertIsNone(self.meter.silent_for("absent")) + + def test_a_running_meter_reports_its_age(self): + import time as _time + + class Alive: + def poll(self): + return None + + self.meter._procs["dock"] = Alive() + self.meter._last_data["dock"] = _time.monotonic() - 5.0 + self.assertAlmostEqual(self.meter.silent_for("dock"), 5.0, delta=0.5) + + def test_a_dead_meter_says_nothing_about_the_hardware(self): + """If pw-cat itself died, its silence is about pw-cat. Treating that + as a stalled device would cycle a card that is working fine.""" + import time as _time + + class Exited: + def poll(self): + return 1 + + self.meter._procs["dock"] = Exited() + self.meter._last_data["dock"] = _time.monotonic() - 999.0 + self.assertIsNone(self.meter.silent_for("dock")) diff --git a/wavexlr/app.py b/wavexlr/app.py index 862896f..70f1b7c 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -10,6 +10,7 @@ import os import sys import threading +import time from .device import WaveDevice from .meter import MeterMonitor @@ -21,7 +22,8 @@ from .mixmatrix import MixMatrix from .sourcedialog import AddSourceDialog from . import (paths, setup, service, sources as sources_module, - mixes as mixes_module, desktop as desktop_module) + mixes as mixes_module, desktop as desktop_module, + recovery as recovery_module) logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") @@ -103,6 +105,7 @@ def __init__(self, **kwargs): # unseeded snapshot draws rows live rather than dead in the meantime. self._refresh_outputs() self.meter = MeterMonitor() + self._stall_watch = recovery_module.StallWatch() self._meter_targets = {} self._wire_matrix_cells() self._autodiscover_elgato_inputs() @@ -952,10 +955,41 @@ def _stream_poll_tick(self): if sources_module.kind(source) == sources_module.KIND_DEVICE: if check_devices: self._refresh_device_meter(source_id, source) + self._check_capture_stall(source_id, source) else: self._refresh_app_meter(source_id) return True + def _check_capture_stall(self, source_id, source): + """Reopen a capture device that enumerated but never started. + + A Wave replugged while running comes back reporting itself healthy at + every layer and delivers no frames at all. Nothing else notices, + because nothing else is looking for the difference between silence + and no data. + """ + node_name = source.get("node_name") + present = node_name in self.mixer.live_captures() + if not present: + self._stall_watch.forget(node_name) + return + silent_for = self.meter.silent_for(source_id) + now = time.monotonic() + if not self._stall_watch.should_recover( + node_name, present, silent_for, now): + return + card = recovery_module.card_name_for(node_name) + if card is None: + return + self._stall_watch.record_attempt(node_name, now) + logging.warning( + "%s has produced no audio for %.0fs; reopening %s", + source.get("name", source_id), silent_for, card) + if recovery_module.cycle_card(card): + # The node is destroyed and recreated by the cycle, so the meter + # is pointing at something that no longer exists. + self._refresh_device_meter(source_id, source) + def _start_meters(self): """Meter every source that has something to meter.""" for source_id in self._sources.keys(): diff --git a/wavexlr/meter.py b/wavexlr/meter.py index b9ef0d7..0a64a2c 100644 --- a/wavexlr/meter.py +++ b/wavexlr/meter.py @@ -12,6 +12,7 @@ import struct import subprocess import threading +import time import gi @@ -30,6 +31,11 @@ def __init__(self): self._threads = {} # source_id -> Thread self._stop_flags = {} # source_id -> threading.Event self._cbs = {} # source_id -> callable(float) + # When each meter last received *any* bytes. A stalled capture device + # delivers nothing rather than delivering zeros, so this distinguishes + # "dead" from "quiet" -- which a peak level cannot, since a muted + # microphone in a quiet room is legitimately near zero. + self._last_data = {} # source_id -> monotonic seconds def start(self, source_id, source_node_name, callback): """Begin streaming peak values for `source_id`. Replaces any existing @@ -72,6 +78,10 @@ def start(self, source_id, source_node_name, callback): self._threads[source_id] = thread self._stop_flags[source_id] = stop_flag self._cbs[source_id] = callback + # Seeded at start, not left unset: a meter that has never received a + # byte is exactly the stall being looked for, and would otherwise + # look like a meter that simply has no history yet. + self._last_data[source_id] = time.monotonic() thread.start() def stop(self, source_id): @@ -81,6 +91,7 @@ def stop(self, source_id): proc = self._procs.pop(source_id, None) self._threads.pop(source_id, None) self._cbs.pop(source_id, None) + self._last_data.pop(source_id, None) if proc is None: return try: @@ -107,6 +118,7 @@ def _reader(self, source_id, proc, stop_flag): data = proc.stdout.read(self.CHUNK_BYTES) if not data or len(data) < 2: break + self._last_data[source_id] = time.monotonic() n = len(data) // 2 samples = struct.unpack(f"<{n}h", data[: n * 2]) peak = max(abs(s) for s in samples) / 32768.0 @@ -117,6 +129,25 @@ def _reader(self, source_id, proc, stop_flag): # subprocess dies (mic unplugged, app closed, etc.) GLib.idle_add(self._dispatch, source_id, 0.0) + def silent_for(self, source_id): + """Seconds since this meter last received any data, or None. + + None means nothing is being measured, which is deliberately NOT the + same as measuring nothing. Two cases return it, and conflating either + with a stalled device would have something act on the silence: + + - no meter is running for that source at all; + - the meter's own subprocess has died, so its silence says something + about pw-cat and nothing whatsoever about the hardware. + """ + last = self._last_data.get(source_id) + if last is None: + return None + proc = self._procs.get(source_id) + if proc is None or proc.poll() is not None: + return None + return time.monotonic() - last + def _dispatch(self, source_id, peak): cb = self._cbs.get(source_id) if cb is not None: diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 68b14e6..75c0e34 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -925,6 +925,14 @@ def poll_streams(self): if added or removed: self._enqueue(("poll",), self._reconcile_all) return added, removed + def live_captures(self): + """node.name set of the capture devices PipeWire currently has. + + Rebound rather than mutated by the worker, so a read from the GTK + thread always sees one whole snapshot rather than a set mid-update. + """ + return self._live_captures + def _refresh_live_captures(self): """Re-snapshot present capture devices. Returns (added, removed) names. diff --git a/wavexlr/recovery.py b/wavexlr/recovery.py new file mode 100644 index 0000000..7e453fa --- /dev/null +++ b/wavexlr/recovery.py @@ -0,0 +1,151 @@ +"""Recovering a capture device that enumerated but never started producing. + +An Elgato Wave replugged while the system is running comes back on the USB +bus, gets its ALSA card, gets a PipeWire node, and reports itself unmuted at +full gain with phantom power on -- and delivers no audio frames at all. Not +quiet frames: none. Every layer says the device is healthy, so nothing +notices, and the microphone is simply dead until the card is opened again. + +The distinction that makes this detectable is between *silence* and *no +data*. A live analogue input always delivers a noise floor; a stalled one +delivers nothing, so a meter reading it blocks forever on its first read. +That is the signal used here -- no bytes at all while the node exists -- and +it is why a level threshold would be the wrong test: a muted microphone in a +quiet room is legitimately near zero and must not be "recovered". + +The remedy is to make ALSA close and reopen the device, which cycling the +card's profile does. Restarting the capture keepalive does not: it exists to +prevent the race, and cannot clear one that has already happened. +""" + +import logging +import re +import subprocess + +# How long a live node may deliver nothing before it counts as stalled. Long +# enough to survive a device changing profile or a meter restarting, short +# enough that nobody finishes a sentence into a dead microphone. +STALL_SECONDS = 8.0 +# A failed recovery must not become a loop: cycling a card is disruptive, and +# a device that is genuinely broken should be left alone to be noticed. +COOLDOWN_SECONDS = 60.0 +MAX_ATTEMPTS = 2 + +log = logging.getLogger(__name__) + + +def card_name_for(node_name): + """The ALSA card behind a capture node, or None. + + alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9-00.mono-fallback + -> alsa_card.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9-00 + + The trailing component is the profile, not part of the device, and the + card name is the device stem with the card prefix. + """ + if not node_name or not node_name.startswith(("alsa_input.", "alsa_output.")): + return None + stem = node_name.split(".", 1)[1] + if "." in stem: + stem = stem.rsplit(".", 1)[0] + return f"alsa_card.{stem}" if stem else None + + +def _pactl(*args, timeout=5): + try: + result = subprocess.run( + ["pactl", *args], capture_output=True, text=True, timeout=timeout, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return None + return result.stdout if result.returncode == 0 else None + + +def active_profile(card_name): + """The card's current profile name, or None if the card is unknown.""" + out = _pactl("list", "cards") + if not out: + return None + wanted = f"Name: {card_name}" + inside = False + for line in out.splitlines(): + stripped = line.strip() + if stripped.startswith("Name: alsa_card."): + inside = stripped == wanted + elif inside and stripped.startswith("Active Profile:"): + return stripped.split(":", 1)[1].strip() + return None + + +def cycle_card(card_name): + """Force ALSA to close and reopen a card. Returns True if it was cycled. + + Through `off` rather than straight back to the same profile: setting a + card to the profile it already has is a no-op, and the point is the close + and reopen, not the profile itself. The profile is restored afterwards + because it is the user's -- OpenWave deliberately puts a Wave into an + input-only profile, and coming back on a different one would silently + change what the device is. + """ + profile = active_profile(card_name) + if not profile or profile == "off": + return False + if _pactl("set-card-profile", card_name, "off") is None: + return False + restored = _pactl("set-card-profile", card_name, profile) + if restored is None: + log.error("left %s off: could not restore profile %s", + card_name, profile) + return False + log.info("recovered %s by cycling profile %s", card_name, profile) + return True + + +class StallWatch: + """Decides when a capture node has stalled, and rate-limits the remedy. + + Kept separate from the acting on it so the decision can be tested without + a sound card: every input is a number or a bool. + """ + + def __init__(self, stall_seconds=STALL_SECONDS, + cooldown_seconds=COOLDOWN_SECONDS, + max_attempts=MAX_ATTEMPTS): + self.stall_seconds = stall_seconds + self.cooldown_seconds = cooldown_seconds + self.max_attempts = max_attempts + self._attempts = {} # node_name -> count + self._last_attempt = {} # node_name -> monotonic time + + def forget(self, node_name): + """A node that went away starts clean when it comes back. + + Attempts are counted per appearance, not for the life of the process: + unplugging and replugging is exactly how the stall arises, so it must + not exhaust the budget from the previous time. + """ + self._attempts.pop(node_name, None) + self._last_attempt.pop(node_name, None) + + def should_recover(self, node_name, node_present, silent_for, now): + """True when this node is stalled and may be acted on right now.""" + if not node_present or node_name is None: + # Absent is not stalled. Cycling a card for a device that has + # been unplugged would fight the person who unplugged it. + return False + if silent_for is None or silent_for < self.stall_seconds: + return False + if self._attempts.get(node_name, 0) >= self.max_attempts: + return False + last = self._last_attempt.get(node_name) + if last is not None and now - last < self.cooldown_seconds: + return False + return True + + def record_attempt(self, node_name, now): + self._attempts[node_name] = self._attempts.get(node_name, 0) + 1 + self._last_attempt[node_name] = now + + def record_recovered(self, node_name): + """Audio came back, so the budget is spent on the next stall only.""" + self.forget(node_name) From cc3cfb0b0e23cb3bbac58ba224d4e26eda06ad4c Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 09:32:10 -0500 Subject: [PATCH 48/99] Only hide into a tray that exists self._tray doubled as "hiding the window is safe", and was set by merely constructing the object. Registering a StatusNotifierItem into a session with no watcher succeeds silently, so on a stock GNOME desktop -- which ships no StatusNotifier host at all; the watcher name appears only once an AppIndicator extension is installed -- OpenWave would hide its window into nothing and leave no way to get it back. Closing the window did it, and the --hide flag did it before the window was ever shown, which is worse: an application running invisibly whose only route back is a tray menu that is also absent. register() now probes for the watcher and reports whether anything will draw the icon. Without one, closing the window quits normally and --hide shows the window and says why in the log. "Open OpenWave" also opens now. It shared a callback with clicking the icon, which toggles, so on an already-visible window the menu item labelled Open hid it -- a toggle wearing the wrong label, and the one control that has to work when the window was started hidden. Found while reviewing a tray design against the code: the design assumed the prerequisite was cosmetic, and it is not. --- README.md | 5 ++++- tests/test_recovery.py | 31 ++++++++++++++++++++++++++++ wavexlr/app.py | 37 ++++++++++++++++++++++++++++++--- wavexlr/tray.py | 46 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d911ce2..fcdb9ef 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,10 @@ from a checkout that has since been installed properly would otherwise keep launching a path that no longer exists. **Start at login** and **Start in the tray** are switches in the sidebar, -under *Startup*. They write `~/.config/autostart/openwave.desktop`, adding +under *Startup*. Starting in the tray needs a tray: GNOME ships no +StatusNotifier host, so without an AppIndicator extension OpenWave shows its +window instead of hiding into nothing, and closing the window quits rather +than making it disappear. They write `~/.config/autostart/openwave.desktop`, adding `--hide` for the tray-only case. Turning autostart off deletes that file; the drawer entry is a separate file and is left alone. diff --git a/tests/test_recovery.py b/tests/test_recovery.py index 5ffdd86..61f0dde 100644 --- a/tests/test_recovery.py +++ b/tests/test_recovery.py @@ -195,3 +195,34 @@ def poll(self): self.meter._procs["dock"] = Exited() self.meter._last_data["dock"] = _time.monotonic() - 999.0 self.assertIsNone(self.meter.silent_for("dock")) + + +class TrayHostProbe(unittest.TestCase): + """Whether a tray exists decides whether hiding the window is safe.""" + + def _probe(self, answer): + import gi + gi.require_version("Gtk", "4.0") + from gi.repository import GLib + from wavexlr.tray import TrayIcon + + class Bus: + def call_sync(self, *a, **k): + if isinstance(answer, Exception): + raise answer + return GLib.Variant("(b)", (answer,)) + + return TrayIcon.host_available(Bus()) + + def test_a_watcher_on_the_bus_means_yes(self): + self.assertTrue(self._probe(True)) + + def test_no_watcher_means_no(self): + """GNOME ships no StatusNotifier host: the name appears only when an + AppIndicator extension is installed.""" + self.assertFalse(self._probe(False)) + + def test_a_bus_error_means_no(self): + from gi.repository import GLib + self.assertFalse(self._probe( + GLib.Error.new_literal(GLib.quark_from_string("g-io"), "x", 0))) diff --git a/wavexlr/app.py b/wavexlr/app.py index 70f1b7c..b9e05b9 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1756,7 +1756,12 @@ def do_activate(self): self._setup_tray() if self._start_hidden: self._start_hidden = False # only first launch - return + if self._tray is None: + logging.warning( + "--hide was asked for but this desktop has no system " + "tray; showing the window instead") + else: + return self._window.present() def _load_css(self): @@ -1799,13 +1804,27 @@ def _on_close_request(self, window): return False # normal close → quit def _setup_tray(self): + """Publish a tray icon, but only claim one if it will be drawn. + + self._tray doubles as "hiding the window is safe", so it must not be + set by merely constructing the object: GNOME ships no StatusNotifier + host, and registering into a session with no watcher succeeds + silently. Hiding into that is a window nobody can get back. + """ from .tray import TrayIcon - self._tray = TrayIcon( + tray = TrayIcon( on_activate=self._toggle_window, + on_open=self._present_window, on_mute=self._toggle_mute, on_quit=self._quit_app, ) - self._tray.register() + if not tray.register(): + logging.info( + "no system tray on this desktop; the window will close " + "normally instead of hiding") + self._tray = None + return + self._tray = tray # Keep app alive when window is hidden self.hold() @@ -1822,12 +1841,24 @@ def _quit_app(self): self.quit() def _toggle_window(self): + """Clicking the tray icon: show if hidden, hide if shown.""" if self._window: if self._window.get_visible(): self._window.set_visible(False) else: self._window.present() + def _present_window(self): + """The "Open OpenWave" menu item. Always opens. + + Separate from the icon click on purpose: a menu item that reads Open + and hides the window when it is already open is a toggle wearing the + wrong label, and it is the only way back to a window that was started + hidden. + """ + if self._window: + self._window.present() + def _show_setup_dialog(self): dialog = Adw.AlertDialog( heading="First-Time Setup", diff --git a/wavexlr/tray.py b/wavexlr/tray.py index 48c1b7f..228a0dd 100644 --- a/wavexlr/tray.py +++ b/wavexlr/tray.py @@ -88,8 +88,13 @@ class TrayIcon: """Minimal StatusNotifierItem tray icon.""" - def __init__(self, on_activate=None, on_mute=None, on_quit=None): + def __init__(self, on_activate=None, on_mute=None, on_quit=None, + on_open=None): self._on_activate = on_activate + # Separate from on_activate: clicking the icon may toggle, but the + # menu item reads "Open OpenWave" and must open. It is also the only + # way back to a window that was started hidden. + self._on_open = on_open or on_activate self._on_mute = on_mute self._on_quit = on_quit self._bus = None @@ -99,7 +104,32 @@ def __init__(self, on_activate=None, on_mute=None, on_quit=None): self._revision = 1 self._menu_items = {} # id -> properties dict + @staticmethod + def host_available(bus=None): + """True when something on this session bus will actually draw us. + + Asked before anything is allowed to depend on the tray existing. + GNOME ships no StatusNotifier host of its own -- the watcher name + appears only when an AppIndicator extension is installed -- so on a + stock GNOME desktop a tray icon is registered successfully and drawn + nowhere, which is indistinguishable from working right up until the + window is hidden into it. + """ + try: + bus = bus or Gio.bus_get_sync(Gio.BusType.SESSION, None) + reply = bus.call_sync( + "org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "NameHasOwner", + GLib.Variant("(s)", ("org.kde.StatusNotifierWatcher",)), + GLib.VariantType.new("(b)"), Gio.DBusCallFlags.NONE, 2000, + None, + ) + except GLib.Error: + return False + return bool(reply.unpack()[0]) + def register(self): + """Publish the tray item. Returns True if a host will draw it.""" self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) self._build_menu_items() @@ -131,6 +161,9 @@ def register(self): None, None, ) + if not self.host_available(self._bus): + return False + # Register with the StatusNotifierWatcher try: self._bus.call_sync( @@ -143,8 +176,11 @@ def register(self): Gio.DBusCallFlags.NONE, -1, None, ) - except Exception: - pass # no watcher running — tray won't show but app still works + return True + except GLib.Error: + # The watcher answered NameHasOwner and then refused the + # registration; whatever the reason, nothing will draw us. + return False def _on_item_call(self, conn, sender, path, iface, method, params, invocation): if method == "Activate": @@ -232,8 +268,8 @@ def _on_menu_call(self, conn, sender, path, iface, method, params, invocation): item_id = params[0] event_id = params[1] if event_id == "clicked": - if item_id == 1 and self._on_activate: - self._on_activate() + if item_id == 1 and self._on_open: + self._on_open() elif item_id == 2 and self._on_mute: self._on_mute() elif item_id == 4 and self._on_quit: From c0298a3eec8d6194c16055962109619ae9995690 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 09:39:21 -0500 Subject: [PATCH 49/99] Remember mix master levels across a reboot Nothing persisted them. A mix master is a plain PipeWire sink volume, the mix sinks are context.objects in PipeWire's own configuration, and the daemon recreates them at unity on every start; WirePlumber does not restore them either, since they are neither streams nor devices it manages. So every mix master silently reset to 100% at each boot, and there was no code anywhere in wavexlr that read or wrote a sink volume at all. They are now remembered in mixes.json under "volumes" and applied once the sinks exist. What is recorded is what the master is actually set to, not only what this window did: anything may move it -- a Stream Deck, pavucontrol, a media key -- and whoever moved it, that is the value that should come back. Hence polling rather than hooking, once every two seconds on the tick that already runs, with a tolerance so the rounding pactl reports does not rewrite the file forever. Observation is gated on the restore having happened, and that gate is the whole feature rather than an optimisation: at boot the sinks exist at unity before OpenWave does, so an observation landing first would persist that unity and destroy the value it exists to protect -- silently, exactly once per boot, which is indistinguishable from never having saved anything. A restore that runs before the mix definitions arrive deliberately leaves the gate shut rather than opening it on an empty set. Read through pactl's JSON output rather than by scraping `pactl list sinks`, whose labels are localised: on a German desktop "Stumm: nein" makes a text scraper read every sink as unmuted. Verified against the real thing rather than in the abstract -- sinks set to unity with OpenWave stopped, OpenWave started, masters back at 55% and the saved value intact. The first attempt at that test was unfaithful (it moved the volume while OpenWave was watching, which is a user action, not a boot) and it was the corrected one that exposed the race above. --- README.md | 26 ++++ tests/support.py | 1 + tests/test_mix_volumes.py | 259 ++++++++++++++++++++++++++++++++++++++ wavexlr/app.py | 1 + wavexlr/mixer.py | 146 ++++++++++++++++++++- 5 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 tests/test_mix_volumes.py diff --git a/README.md b/README.md index fcdb9ef..b3b2bff 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ all, so on that hardware the app is the only way to switch it. fader can cover every game or two music players), or a hardware capture device such as a headset microphone. One row may be the catch-all for anything unmatched. +- **Levels survive a reboot** — a mix master is a plain PipeWire sink volume, + and the mix sinks are `context.objects` in PipeWire's own configuration, so + the daemon recreates them at unity on every start and WirePlumber does not + restore them: they are neither streams nor devices it manages. OpenWave + remembers them itself and puts them back. - **Per-mix output** — every mix chooses its own output device, or none at all for a mix that exists only to be captured. A mix keeps playing when the window is closed. @@ -70,6 +75,27 @@ Wave devices use USB Class control transfers on endpoint 0 for device configurat Both devices speak the same vendor protocol (`bRequest` 0x85 read / 0x05 write) but with different config layouts: the Wave XLR uses a 34-byte block (gain uint16 @0, mute @4, HP volume int16 Q8.8 @9, knob mode @14, low-Z @33), the Wave:3 a 16-byte block (gain uint16 Q8.8 dB @0, mute @4, HP volume int16 Q8.8 @7, monitor mix uint16 Q8.8 percent @10, dial mode @12 — 1=gain, 2=headphones, 3=mix). Per-model constants live in `wavexlr/profiles.py`; `python3 -m wavexlr.probe` (`dump` / `watch` / `poke`) verifies a device against its profile and helps map new fields. The device services vendor transfers from only one process at a time, so quit OpenWave before probing. +## Mix levels and reboots + +A mix master is a plain PipeWire sink volume, and the mix sinks are +`context.objects` in PipeWire's configuration — recreated by the daemon on +every start, at unity, with no memory. WirePlumber does not restore them +either, because they are neither streams nor devices it manages. Left alone, +every mix master silently resets to 100% at each boot, including anything set +from a control surface. + +OpenWave remembers them in `mixes.json` under `volumes` and applies them once +the sinks exist. It records what the master is actually set to rather than +only what its own window did, because anything may move it — a Stream Deck, +`pavucontrol`, a media key — and whoever moved it, that is the value that +should come back. + +Observation is gated on the restore having happened, and that gate is the +point rather than an optimisation. At boot the sinks exist at unity before +OpenWave does; an observation landing first would persist that unity and +destroy the value it exists to protect — silently, exactly once per boot, +which is indistinguishable from never having saved anything. + ## Stalled capture A Wave replugged while the system is running enumerates, gets its ALSA card diff --git a/tests/support.py b/tests/support.py index 0bfb4da..824ebd0 100644 --- a/tests/support.py +++ b/tests/support.py @@ -55,6 +55,7 @@ def bare_mixer(**attrs): mx.mic = None mx.hp = None mx._started = False + mx._volumes_restored = True # set_cell and friends enqueue their reconcile even with no worker # running. The queue is the seam: work lands in _pending and stays there, # so a test can call the real entry points and inspect the state they diff --git a/tests/test_mix_volumes.py b/tests/test_mix_volumes.py new file mode 100644 index 0000000..f77d2d8 --- /dev/null +++ b/tests/test_mix_volumes.py @@ -0,0 +1,259 @@ +"""Remembering what a mix master is set to. + +The mix sinks are context.objects in PipeWire's own configuration, so the +daemon recreates them from scratch on every start, at unity, with no memory. +WirePlumber does not restore them either -- they are neither streams nor +devices it manages -- so without this every mix master silently resets to +100% at each boot, including anything set from a control surface. +""" + +import json +import unittest + +from wavexlr import mixer as mixer_mod +from .support import bare_mixer, temp_config + +MIXES = { + "personal": {"id": "personal", "name": "Personal Mix", + "sink": "openwave_personal_mix"}, + "chat": {"id": "chat", "name": "Chat Mix", "sink": "openwave_chat_mix"}, + "quiet": {"id": "quiet", "name": "Unrouted", "sink": ""}, +} + + +class Remembering(unittest.TestCase): + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self.mixer = bare_mixer(_mixes=dict(MIXES)) + + def tearDown(self): + self._ctx.__exit__(None, None, None) + + def test_an_unseen_mix_has_nothing_remembered(self): + self.assertIsNone(self.mixer.mix_volume("personal")) + + def test_a_level_survives_a_round_trip(self): + self.mixer.remember_mix_volume("personal", 0.62, False) + self.assertEqual(self.mixer.mix_volume("personal"), (0.62, False)) + + def test_a_mute_is_remembered_with_it(self): + self.mixer.remember_mix_volume("chat", 0.4, True) + self.assertEqual(self.mixer.mix_volume("chat"), (0.4, True)) + + def test_it_reaches_disk_immediately(self): + """A reboot is not a graceful shutdown; nothing may wait for one.""" + self.mixer.remember_mix_volume("personal", 0.33, False) + stored = json.load(open(mixer_mod.CONFIG_PATH)) + self.assertEqual(stored["volumes"]["personal"], + {"volume": 0.33, "muted": False}) + + def test_an_unchanged_level_is_not_rewritten(self): + """Observed twice a second; rewriting the file each time would be a + write every tick for the life of the process.""" + self.assertTrue(self.mixer.remember_mix_volume("personal", 0.5, False)) + self.assertFalse(self.mixer.remember_mix_volume("personal", 0.5, False)) + + def test_a_tiny_drift_is_not_a_change(self): + """pactl reports percent, so a value set as 0.62 reads back rounded; + without a tolerance that alone would rewrite the file forever.""" + self.mixer.remember_mix_volume("personal", 0.62, False) + self.assertFalse( + self.mixer.remember_mix_volume("personal", 0.6203, False)) + + def test_a_real_change_is_written(self): + self.mixer.remember_mix_volume("personal", 0.5, False) + self.assertTrue(self.mixer.remember_mix_volume("personal", 0.7, False)) + + def test_a_mute_alone_is_a_change(self): + self.mixer.remember_mix_volume("personal", 0.5, False) + self.assertTrue(self.mixer.remember_mix_volume("personal", 0.5, True)) + + def test_levels_are_clamped(self): + self.mixer.remember_mix_volume("personal", 4.0, False) + self.assertEqual(self.mixer.mix_volume("personal"), (1.0, False)) + + def test_corrupt_state_reads_as_unknown_rather_than_raising(self): + """Restoring runs at startup; a bad value must not stop the mixer.""" + for bad in ("nonsense", {"volume": "loud"}, {}, None, []): + self.mixer._state["volumes"] = {"personal": bad} + self.assertIsNone(self.mixer.mix_volume("personal"), bad) + + def test_volumes_is_not_mistaken_for_a_cell(self): + """Cell keys are "."; a reserved bare word is not one, + and treating it as a cell would put a dict where a level belongs.""" + self.mixer.remember_mix_volume("personal", 0.5, False) + self.mixer._state["music.personal"] = {"volume": 0.5, "muted": False} + self.assertEqual(list(self.mixer.cells()), ["music.personal"]) + + +class Restoring(unittest.TestCase): + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self.applied = [] + self._vol = mixer_mod._pactl_set_sink_volume + self._mute = mixer_mod._pactl_set_sink_mute + mixer_mod._pactl_set_sink_volume = \ + lambda s, v: self.applied.append(("volume", s, round(v, 3))) + mixer_mod._pactl_set_sink_mute = \ + lambda s, m: self.applied.append(("mute", s, m)) + self.mixer = bare_mixer(_mixes=dict(MIXES)) + + def tearDown(self): + mixer_mod._pactl_set_sink_volume = self._vol + mixer_mod._pactl_set_sink_mute = self._mute + self._ctx.__exit__(None, None, None) + + def test_it_puts_back_what_was_remembered(self): + self.mixer.remember_mix_volume("personal", 0.62, False) + self.mixer.restore_mix_volumes() + self.assertIn(("volume", "openwave_personal_mix", 0.62), self.applied) + self.assertIn(("mute", "openwave_personal_mix", False), self.applied) + + def test_a_mix_never_seen_is_left_at_whatever_it_came_up_as(self): + """Restoring an unknown mix to a made-up default would be inventing a + level nobody chose.""" + self.mixer.restore_mix_volumes() + self.assertEqual(self.applied, []) + + def test_a_mix_routed_nowhere_is_skipped(self): + self.mixer.remember_mix_volume("quiet", 0.5, False) + self.mixer.restore_mix_volumes() + self.assertEqual(self.applied, []) + + +class Observing(unittest.TestCase): + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self._real = mixer_mod._pactl_sink_volumes + self.live = {} + mixer_mod._pactl_sink_volumes = lambda: self.live + self.mixer = bare_mixer(_mixes=dict(MIXES)) + + def tearDown(self): + mixer_mod._pactl_sink_volumes = self._real + self._ctx.__exit__(None, None, None) + + def test_it_records_whatever_moved_the_master(self): + """Polled rather than hooked: anything may move a sink volume -- this + window, a Stream Deck, pavucontrol, a media key -- and whoever moved + it, that is the value that should come back after a reboot.""" + self.live = {"openwave_personal_mix": (0.45, False)} + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("personal"), (0.45, False)) + + def test_it_records_a_mute_made_elsewhere(self): + self.live = {"openwave_chat_mix": (0.8, True)} + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("chat"), (0.8, True)) + + def test_a_sink_that_is_not_there_is_not_invented(self): + self.live = {} + self.mixer.observe_mix_volumes() + self.assertIsNone(self.mixer.mix_volume("personal")) + + def test_pactl_failing_does_not_erase_what_was_known(self): + self.mixer.remember_mix_volume("personal", 0.62, False) + self.live = {} + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("personal"), (0.62, False)) + + +class ReadingPactl(unittest.TestCase): + def _parse(self, payload): + class Result: + returncode = 0 + stdout = json.dumps(payload) + real = mixer_mod.subprocess.run + mixer_mod.subprocess.run = lambda *a, **k: Result() + try: + return mixer_mod._pactl_sink_volumes() + finally: + mixer_mod.subprocess.run = real + + def test_it_reads_volume_and_mute(self): + got = self._parse([{ + "name": "openwave_chat_mix", "mute": True, + "volume": {"front-left": {"value": 32768}, + "front-right": {"value": 32768}}, + }]) + self.assertEqual(got["openwave_chat_mix"][1], True) + self.assertAlmostEqual(got["openwave_chat_mix"][0], 0.5, places=2) + + def test_the_loudest_channel_wins(self): + """A mix balanced off-centre still has one master; taking the first + channel would report the quiet side as the level.""" + got = self._parse([{ + "name": "s", "mute": False, + "volume": {"front-left": {"value": 16384}, + "front-right": {"value": 65536}}, + }]) + self.assertAlmostEqual(got["s"][0], 1.0, places=2) + + def test_a_sink_with_no_channels_is_skipped(self): + self.assertEqual(self._parse([{"name": "s", "volume": {}}]), {}) + + def test_junk_is_not_an_exception(self): + """This runs on a poll tick; raising here would stop the tick.""" + for payload in ({}, "text", [None], [{"volume": None}]): + self.assertIsInstance(self._parse(payload), dict) + + +if __name__ == "__main__": + unittest.main() + + +class TheBootRace(unittest.TestCase): + """The one that matters: at boot the sinks exist at unity before + OpenWave does. An observation that lands before the restore persists that + unity and destroys the saved value -- silently, once per boot, which is + indistinguishable from never having saved anything.""" + + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self._real = mixer_mod._pactl_sink_volumes + self._vol = mixer_mod._pactl_set_sink_volume + self._mute = mixer_mod._pactl_set_sink_mute + self.applied = [] + mixer_mod._pactl_set_sink_volume = \ + lambda s, v: self.applied.append((s, round(v, 3))) + mixer_mod._pactl_set_sink_mute = lambda s, m: None + # What the daemon just created the sinks at. + mixer_mod._pactl_sink_volumes = lambda: { + "openwave_personal_mix": (1.0, False)} + self.mixer = bare_mixer(_mixes=dict(MIXES)) + self.mixer._volumes_restored = False + + def tearDown(self): + mixer_mod._pactl_sink_volumes = self._real + mixer_mod._pactl_set_sink_volume = self._vol + mixer_mod._pactl_set_sink_mute = self._mute + self._ctx.__exit__(None, None, None) + + def test_observing_before_restoring_changes_nothing(self): + self.mixer.remember_mix_volume("personal", 0.62, False) + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("personal"), (0.62, False)) + + def test_the_saved_value_is_what_gets_applied(self): + self.mixer.remember_mix_volume("personal", 0.62, False) + self.assertTrue(self.mixer.restore_mix_volumes()) + self.assertIn(("openwave_personal_mix", 0.62), self.applied) + + def test_observation_resumes_once_restored(self): + self.mixer.remember_mix_volume("personal", 0.62, False) + self.mixer.restore_mix_volumes() + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("personal"), (1.0, False)) + + def test_restoring_with_no_mixes_leaves_the_gate_shut(self): + """Called before the mix definitions arrive, it must not open the + gate: doing so would let the next tick persist unity.""" + self.mixer._mixes = {} + self.mixer.remember_mix_volume("personal", 0.62, False) + self.assertFalse(self.mixer.restore_mix_volumes()) + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("personal"), (0.62, False)) diff --git a/wavexlr/app.py b/wavexlr/app.py index b9e05b9..7960469 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -946,6 +946,7 @@ def _start_stream_poll(self): def _stream_poll_tick(self): self.mixer.poll_streams() + self.mixer.observe_mix_volumes() self._device_poll_countdown -= 1 check_devices = self._device_poll_countdown <= 0 if check_devices: diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 75c0e34..a54fbe0 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -69,6 +69,55 @@ def source_sink_name(source_id): return f"{SOURCE_SINK_PREFIX}{source_id}" +def _pactl_sink_volumes(): + """{sink_name: (volume 0-1, muted)} for every sink, in one call. + + JSON rather than parsing `pactl list sinks`, whose labels are localised: + a German desktop reports "Stumm: nein" and a text scraper silently reads + every sink as unmuted. + """ + try: + result = subprocess.run( + ["pactl", "--format=json", "list", "sinks"], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return {} + if result.returncode != 0: + return {} + try: + sinks = json.loads(result.stdout) + except (ValueError, TypeError): + return {} + out = {} + for sink in sinks if isinstance(sinks, list) else (): + if not isinstance(sink, dict): + continue + name = sink.get("name") + channels = (sink.get("volume") or {}).values() + levels = [c.get("value", 0) / 65536.0 for c in channels + if isinstance(c, dict)] + if name and levels: + out[name] = (max(levels), bool(sink.get("mute"))) + return out + + +def _pactl_set_sink_volume(sink_name, volume): + _run_quiet(["pactl", "set-sink-volume", sink_name, + f"{round(max(0.0, min(1.0, volume)) * 100)}%"]) + + +def _pactl_set_sink_mute(sink_name, muted): + _run_quiet(["pactl", "set-sink-mute", sink_name, "1" if muted else "0"]) + + +def _run_quiet(argv): + try: + subprocess.run(argv, capture_output=True, text=True, timeout=3) + except (FileNotFoundError, subprocess.SubprocessError): + pass + + def _move_stream(serial, sink_name): """Move a stream onto a sink. `serial` is PulseAudio's index for it.""" try: @@ -96,6 +145,7 @@ def _set_pdeathsig(): # keys are always ".", so a bare word cannot collide with one. # Per-mix output devices live under a nested reserved key. Cell keys are # always ".", so a dot-free word cannot collide with one. +VOLUMES_STATE_KEY = "volumes" OUTPUTS_STATE_KEY = "outputs" # Superseded scalar holding the Personal Mix's output. Still written for one # release so an older build reading this file keeps working. @@ -521,6 +571,7 @@ def __init__(self): # route cells into sinks it has not yet created or swept, so # set_sources/set_mixes stay silent until it has run once. self._started = False + self._volumes_restored = False self.mic, self.hp = find_wave_xlr_alsa() # Background worker: every operation that talks to pw-loopback / @@ -610,7 +661,11 @@ def get_cell(self, source_id, mix_id): ) def cells(self): - """Per-cell state only; reserved scalar keys are not cells.""" + """Per-cell state only; reserved scalar keys are not cells. + + Cell keys are ".", and every reserved key -- outputs, + output, volumes -- is a bare word, so the dot is the whole test. + """ return {k: v for k, v in self._state.items() if "." in k} def _default_output_for(self, mix_id): @@ -925,6 +980,91 @@ def poll_streams(self): if added or removed: self._enqueue(("poll",), self._reconcile_all) return added, removed + # ------------------------------------------------------------ volumes + def _volumes(self): + volumes = self._state.get(VOLUMES_STATE_KEY) + if not isinstance(volumes, dict): + volumes = {} + self._state[VOLUMES_STATE_KEY] = volumes + return volumes + + def mix_volume(self, mix_id): + """The remembered (volume, muted) for a mix, or None if unseen.""" + with self._lock: + entry = self._volumes().get(mix_id) + if not isinstance(entry, dict): + return None + try: + return max(0.0, min(1.0, float(entry["volume"]))), \ + bool(entry.get("muted", False)) + except (KeyError, TypeError, ValueError): + return None + + def remember_mix_volume(self, mix_id, volume, muted): + """Record what a mix's master is set to. Returns True if it changed.""" + volume = max(0.0, min(1.0, float(volume))) + with self._lock: + volumes = self._volumes() + entry = volumes.get(mix_id) + if isinstance(entry, dict) \ + and abs(entry.get("volume", -1) - volume) < 0.005 \ + and bool(entry.get("muted")) == bool(muted): + return False + volumes[mix_id] = {"volume": volume, "muted": bool(muted)} + self._save_state() + return True + + def restore_mix_volumes(self): + """Put the mix masters back to what they were. Returns True if done. + + The mix sinks are context.objects in PipeWire's own configuration, so + the daemon recreates them from scratch on every start and they come + up at unity with no memory of anything. WirePlumber does not restore + them either -- they are not streams and not devices it manages -- so + without this every mix master silently resets to 100% at each boot, + including any set from a control surface. + """ + if not self._mixes: + # Nothing to restore onto yet. Crucially this leaves the gate + # shut, so no observation can run and persist the unity the + # daemon just created the sinks at. + return False + for mix_id, mix in list(self._mixes.items()): + sink = mix.get("sink") + remembered = self.mix_volume(mix_id) + if not sink or remembered is None: + continue + volume, muted = remembered + _pactl_set_sink_volume(sink, volume) + _pactl_set_sink_mute(sink, muted) + self._volumes_restored = True + return True + + def observe_mix_volumes(self): + """Persist what the mix masters are actually set to right now. + + Polled rather than hooked, because the master is a plain PipeWire + sink volume and anything may move it -- this window, a Stream Deck, + pavucontrol, a media key. Whoever moved it, the value is what should + come back after a reboot. + + Gated on the restore having happened, and that gate is the whole + point. At boot the sinks are created at unity before OpenWave is + running; an observation that landed first would persist that unity + and destroy the very value it exists to protect -- silently, and + exactly once per boot, which is indistinguishable from not saving at + all. + """ + if not self._volumes_restored: + return + live = _pactl_sink_volumes() + if not live: + return + for mix_id, mix in list(self._mixes.items()): + entry = live.get(mix.get("sink")) + if entry is not None: + self.remember_mix_volume(mix_id, entry[0], entry[1]) + def live_captures(self): """node.name set of the capture devices PipeWire currently has. @@ -1010,6 +1150,10 @@ def _do_start(self): self._refresh_live_captures() self._started = True self._reconcile_all() + # After the sinks exist and before anything observes them: restoring + # first means the first observation sees the restored value rather + # than persisting the unity the daemon just created them at. + self.restore_mix_volumes() def _pin_unity(self, node_name): """Force a plumbing node to unity gain, unmuted. From e3988fdc141bfe89344714aa727f5f9d1d961b69 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 11:42:03 -0500 Subject: [PATCH 50/99] Survive an install prefix and an icon theme that are not the default Two ways the application assumed the environment it was written on, both found by installing it fresh on Arch under Plasma. The first is the failure b3fe909 already fixed once, returning with the same message. The walk it introduced resolves data by climbing from __file__ until /share/openwave appears, which assumes the layout //wavexlr -- and the Makefile does not produce that. SITEPKG comes from the interpreter as an absolute path, so it does not move when PREFIX does. Installing with PREFIX=/usr/local against a distribution whose site-packages is /usr/lib/python3.N/site-packages puts the module under /usr and share/openwave under /usr/local, and no ancestor of the module is ever /usr/local. The walk comes up empty and run_setup() aborts on a rule the install had just written, before that rule and the service are in place. /usr/local is the Makefile's own default and install.sh's, so the advertised curl-pipe install is broken on any distribution that keeps site-packages under /usr; it happens to work on the ones where the two prefixes coincide. paths.py now tries sys.prefix, sys.base_prefix, /usr and /usr/local once the walk has failed, in that order and after it, so a self-consistent install still resolves against its own prefix rather than a stale one next door. The Makefile warns when PREFIX is not above site-packages and names the prefix that is. It warns rather than refuses: a staged DESTDIR tree or a store path may split them deliberately. Writing the test for that exposed a second fault in the same walk, which reading it had not. The climb runs until the path stops changing, so it always ends at /, and on a merged-usr system /bin exists -- bin_file answered /bin/openwave-daemon for every install there has ever been. That defeats the rule the other half of b3fe909 depends on, that a generated service unit points at the install it was generated from and not at whatever is first on PATH later. The root was never anybody's PREFIX, so _prefixes() stops before it. The second is icons. GTK draws the broken-image glyph rather than falling back when the active theme has no such name, and Breeze -- what a Plasma session hands a GTK application -- has none of seven Adwaita names used here. The Browser row's web-browser-symbolic is the one that showed it; list-drag-handle-symbolic had been a broken glyph on every reorderable row as well, unnoticed because it reads as decoration. The seven were established by asking GTK, not by scanning the icon directories, and the difference matters: nine of these names are absent from Breeze on disk and two of them resolve anyway out of GTK's built-in resources, so a disk scan overstates the problem and a guess at which part of the overstatement was real would have replaced two icons that were never broken. The substitution happens when a name is drawn, not when it is chosen. icon_name is stored -- it is written into sources.json and mixes.json and travels with the configuration -- so rewriting it would fix a fresh seed and leave every configuration that already exists broken, which is exactly the case that showed the bug. Resolving at draw time needs no migration, follows a theme change in both directions, and leaves a deliberate choice recorded as the user made it. The preferred name still wins wherever the theme has it, so Adwaita renders exactly as before. Twenty tests, none of which need a display or a sound card: the icon table against a fake theme, the prefixes against temporary trees laid out in each shape an install can take, including the split one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- Makefile | 21 +++++++- tests/test_icons.py | 84 +++++++++++++++++++++++++++++ tests/test_paths.py | 124 +++++++++++++++++++++++++++++++++++++++++++ wavexlr/icons.py | 97 +++++++++++++++++++++++++++++++++ wavexlr/mixdialog.py | 3 +- wavexlr/mixmatrix.py | 15 +++--- wavexlr/paths.py | 47 ++++++++++++++-- 7 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 tests/test_icons.py create mode 100644 tests/test_paths.py create mode 100644 wavexlr/icons.py diff --git a/Makefile b/Makefile index 1057de4..0a8c3ab 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,11 @@ DOCDIR = $(DATADIR)/doc/openwave LICENSEDIR = $(DATADIR)/licenses/openwave SITEPKG := $(shell $(PYTHON) -c "import site; print(site.getsitepackages()[0])") +PYPREFIX := $(shell $(PYTHON) -c "import sys; print(sys.prefix)") -.PHONY: install uninstall +.PHONY: install uninstall check-prefix -install: +install: check-prefix install -dm755 $(DESTDIR)$(SITEPKG)/wavexlr install -m644 $(wildcard wavexlr/*.py) wavexlr/style.css $(DESTDIR)$(SITEPKG)/wavexlr/ install -dm755 $(BINDIR) @@ -36,3 +37,19 @@ uninstall: rm -rf $(APPDIR) rm -rf $(DOCDIR) rm -rf $(LICENSEDIR) + +# site-packages is chosen by the interpreter and is an absolute path: it does +# not move when PREFIX does. If PREFIX is not above it, wavexlr/ and +# share/openwave/ land under different prefixes and nothing above the installed +# module is PREFIX, so paths.py can only find the data through its fallback +# list. That still works, but the install is not self-describing -- warn, do +# not fail, because a staged DESTDIR tree or a store path may mean it. +check-prefix: + @case '$(SITEPKG)' in \ + '$(PREFIX)'/*) ;; \ + *) printf '\033[1;33mwarning:\033[0m PREFIX=%s, but this interpreter installs modules to\n' '$(PREFIX)' >&2; \ + printf ' %s (prefix %s).\n' '$(SITEPKG)' '$(PYPREFIX)' >&2; \ + printf ' wavexlr/ and share/openwave/ will land under different prefixes;\n' >&2; \ + printf ' paths.py finds the data only via its fallback list.\n' >&2; \ + printf ' Use PREFIX=%s to keep the install self-consistent.\n' '$(PYPREFIX)' >&2 ;; \ + esac diff --git a/tests/test_icons.py b/tests/test_icons.py new file mode 100644 index 0000000..a248452 --- /dev/null +++ b/tests/test_icons.py @@ -0,0 +1,84 @@ +"""Substituting an icon name the active theme does not have. + +GTK draws the broken-image glyph rather than falling back, and Breeze -- what a +Plasma session hands a GTK application -- lacks several of the Adwaita names the +UI uses. The Browser row's web-browser-symbolic is the one that showed it. + +icon_name is stored in sources.json and mixes.json, so the substitution belongs +at draw time: a configuration written under one theme has to render under +another, and the user's recorded choice must survive the trip back. +""" + +import unittest +from unittest import mock + +from wavexlr import icons + + +class FakeTheme: + """Stands in for the display's icon theme, which tests have no display for.""" + + def __init__(self, *available): + self.available = set(available) + + def has_icon(self, name): + return name in self.available + + +class Resolving(unittest.TestCase): + def setUp(self): + icons._cache.clear() + self.addCleanup(icons._cache.clear) + + def theme(self, *available): + ctx = mock.patch.object(icons, "_theme", lambda: FakeTheme(*available)) + ctx.start() + self.addCleanup(ctx.stop) + + def test_a_name_the_theme_has_is_left_alone(self): + """Adwaita must be entirely unaffected by any of this.""" + self.theme("web-browser-symbolic") + self.assertEqual(icons.resolve("web-browser-symbolic"), + "web-browser-symbolic") + + def test_a_missing_name_becomes_one_the_theme_has(self): + """The regression: Breeze has no web-browser-symbolic.""" + self.theme("internet-web-browser-symbolic") + self.assertEqual(icons.resolve("web-browser-symbolic"), + "internet-web-browser-symbolic") + + def test_it_keeps_looking_past_an_absent_alternative(self): + self.theme("globe-symbolic") + self.assertEqual(icons.resolve("web-browser-symbolic"), + "globe-symbolic") + + def test_an_unknown_name_is_returned_untouched(self): + """No table for it means no better guess than what was asked for.""" + self.theme() + self.assertEqual(icons.resolve("nonesuch-symbolic"), "nonesuch-symbolic") + + def test_no_display_changes_nothing(self): + """Headless -- a daemon importing the module must not crash on it.""" + ctx = mock.patch.object(icons, "_theme", lambda: None) + ctx.start() + self.addCleanup(ctx.stop) + self.assertEqual(icons.resolve("web-browser-symbolic"), + "web-browser-symbolic") + + def test_an_empty_name_is_not_looked_up(self): + self.theme() + self.assertEqual(icons.resolve(""), "") + self.assertIsNone(icons.resolve(None)) + + def test_every_alternative_is_itself_a_plausible_icon_name(self): + """A typo here would silently become the broken glyph it replaces.""" + for preferred, alternatives in icons._ALTERNATIVES.items(): + self.assertTrue(preferred.endswith("-symbolic"), preferred) + self.assertTrue(alternatives, f"{preferred} has no alternatives") + for alternative in alternatives: + self.assertTrue(alternative.endswith("-symbolic"), alternative) + self.assertNotEqual(alternative, preferred) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..f89ffe2 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,124 @@ +"""Finding the files the Makefile installed, whatever PREFIX it was given. + +site-packages is chosen by the interpreter and is absolute, so it does not move +when PREFIX does. Installing with the Makefile's own default PREFIX=/usr/local +on a distribution whose site-packages is /usr/lib/python3.N/site-packages +therefore splits the install: the module under /usr, share/openwave under +/usr/local. Walking up from the module never reaches /usr/local, so the +WirePlumber rule was reported missing by first-run setup on an install that had +in fact just written it -- and setup aborted before the rule and the service +were in place. +""" + +import os +import unittest +from unittest import mock + +from wavexlr import paths + +RULE = ("wireplumber", "51-openwave-wave-xlr.conf") + + +def tree(root, *relative_dirs): + """Create directories under root and return root.""" + for d in relative_dirs: + os.makedirs(os.path.join(root, d), exist_ok=True) + return root + + +def touch(path, mode=0o644): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as fh: + fh.write("") + os.chmod(path, mode) + return path + + +class Layouts(unittest.TestCase): + """Each shape of install the Makefile and a checkout can produce.""" + + def setUp(self): + ctx = mock.patch.object(paths, "_FALLBACK_PREFIXES", ()) + ctx.start() + self.addCleanup(ctx.stop) + + import tempfile + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = self.tmp.name + + def at(self, *parts): + return os.path.join(self.root, *parts) + + def module_at(self, *parts): + d = self.at(*parts) + os.makedirs(d, exist_ok=True) + ctx = mock.patch.object(paths, "_MODULE_DIR", d) + ctx.start() + self.addCleanup(ctx.stop) + return d + + def fallbacks(self, *prefixes): + ctx = mock.patch.object(paths, "_FALLBACK_PREFIXES", prefixes) + ctx.start() + self.addCleanup(ctx.stop) + + def test_a_checkout_keeps_its_data_beside_the_package(self): + self.module_at("checkout", "wavexlr") + want = touch(self.at("checkout", *RULE)) + self.assertEqual(paths.data_file(*RULE), want) + + def test_a_matching_prefix_is_found_by_walking_up(self): + """PREFIX=/usr with site-packages under /usr: an ancestor holds it.""" + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + want = touch(self.at("usr", "share", "openwave", *RULE)) + self.assertEqual(paths.data_file(*RULE), want) + + def test_a_split_install_is_still_found(self): + """The regression: PREFIX=/usr/local, site-packages under /usr. + + No ancestor of the module is the data's prefix, so without the + fallback list this returned None and first-run setup aborted. + """ + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + self.fallbacks(self.at("usr", "local")) + want = touch(self.at("usr", "local", "share", "openwave", *RULE)) + self.assertEqual(paths.data_file(*RULE), want) + + def test_the_module_own_prefix_wins_over_a_fallback(self): + """Two installs present: the one this module belongs to is the one.""" + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + self.fallbacks(self.at("usr", "local")) + mine = touch(self.at("usr", "share", "openwave", *RULE)) + touch(self.at("usr", "local", "share", "openwave", *RULE)) + self.assertEqual(paths.data_file(*RULE), mine) + + def test_genuinely_missing_is_still_None(self): + """The caller reports it; it must not be masked by a stale install.""" + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + self.assertIsNone(paths.data_file(*RULE)) + + +class Launchers(Layouts): + """bin/ follows PREFIX too, so bin_file splits the same way.""" + + def test_a_launcher_under_the_own_prefix_is_found(self): + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + want = touch(self.at("usr", "bin", "openwave-daemon"), 0o755) + self.assertEqual(paths.bin_file("openwave-daemon"), want) + + def test_a_split_install_finds_its_launcher(self): + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + self.fallbacks(self.at("usr", "local")) + want = touch(self.at("usr", "local", "bin", "openwave-daemon"), 0o755) + self.assertEqual(paths.bin_file("openwave-daemon"), want) + + def test_a_non_executable_file_is_not_a_launcher(self): + """A service unit pointing at it would fail at start, not here.""" + self.module_at("usr", "lib", "python3.14", "site-packages", "wavexlr") + touch(self.at("usr", "bin", "openwave-daemon"), 0o644) + self.assertIsNone(paths.bin_file("openwave-daemon")) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/icons.py b/wavexlr/icons.py new file mode 100644 index 0000000..18700b8 --- /dev/null +++ b/wavexlr/icons.py @@ -0,0 +1,97 @@ +"""Icon names that survive a theme which is not Adwaita. + +The names used throughout the UI are the Adwaita/freedesktop ones. GTK does not +fall back when the active theme lacks one -- it draws the broken-image glyph -- +and Breeze, which is what a Plasma session hands a GTK application, is missing +several of them. A fresh install on KDE therefore showed a missing-image icon +where the Browser row's globe belongs. + +The substitution has to happen when a name is drawn rather than when it is +chosen, because icon_name is stored: it is written into sources.json and +mixes.json and travels with the configuration. Rewriting the stored name would +fix one machine and corrupt the choice for the next, and would do nothing for a +configuration that already exists -- which is exactly the case that showed the +bug. Resolving at draw time leaves the user's choice intact, follows a theme +change in either direction, and needs no migration. +""" + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +# Preferred name -> names to try when the active theme does not have it, best +# first. Every alternative here was checked against Breeze; the preferred name +# is still used whenever the theme has it, so Adwaita is unaffected. +_ALTERNATIVES = { + "web-browser-symbolic": ( + "internet-web-browser-symbolic", + "applications-internet-symbolic", + "globe-symbolic", + ), + "input-gaming-symbolic": ( + "applications-games-symbolic", + "input-gamepad-symbolic", + ), + "audio-x-generic-symbolic": ( + "multimedia-player-symbolic", + "media-optical-audio-symbolic", + ), + "list-drag-handle-symbolic": ( + "view-list-symbolic", + "open-menu-symbolic", + ), + "network-transmit-symbolic": ( + "network-wired-symbolic", + "network-connect-symbolic", + ), + "preferences-desktop-multimedia-symbolic": ( + "multimedia-player-symbolic", + "applications-multimedia-symbolic", + ), + "video-display-symbolic": ( + "computer-symbolic", + "preferences-desktop-display-symbolic", + ), +} + +_cache = {} +_watched = False + + +def _theme(): + """The display's icon theme, or None when there is no display yet.""" + global _watched + display = Gdk.Display.get_default() + if display is None: + return None + theme = Gtk.IconTheme.get_for_display(display) + if theme is not None and not _watched: + # A theme change makes every earlier answer stale, including the ones + # that needed no substitution. + theme.connect("changed", lambda *_: _cache.clear()) + _watched = True + return theme + + +def resolve(name): + """Return name, or the nearest name the active theme actually has. + + Unknown names are returned untouched: a theme we have no table for is not + improved by guessing, and the broken glyph is at least honest about it. + """ + if not name: + return name + if name in _cache: + return _cache[name] + + theme = _theme() + chosen = name + if theme is not None and not theme.has_icon(name): + for alternative in _ALTERNATIVES.get(name, ()): + if theme.has_icon(alternative): + chosen = alternative + break + + _cache[name] = chosen + return chosen diff --git a/wavexlr/mixdialog.py b/wavexlr/mixdialog.py index 6add745..f93d351 100644 --- a/wavexlr/mixdialog.py +++ b/wavexlr/mixdialog.py @@ -11,6 +11,7 @@ gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GObject, Pango # noqa: E402 +from . import icons from .mixes import DEFAULT_ICON ICON_CHOICES = ( @@ -129,7 +130,7 @@ def _build_page(self, heading, confirm_label, name): preselect = None for icon, tooltip in choices: - img = Gtk.Image.new_from_icon_name(icon) + img = Gtk.Image.new_from_icon_name(icons.resolve(icon)) img.set_pixel_size(28) child = Gtk.FlowBoxChild() child.set_child(img) diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index a392cf5..1c059e2 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -13,6 +13,8 @@ gi.require_version("Adw", "1") from gi.repository import Gtk, Adw, GObject, Gdk, Pango # noqa: E402 +from . import icons + def _percent_label(): """A fixed-width percentage readout for a 0..1 slider. @@ -417,7 +419,7 @@ def __init__(self, *, title, subtitle, icon_name): ) self.append(inner) - self._icon = Gtk.Image.new_from_icon_name(icon_name) + self._icon = Gtk.Image.new_from_icon_name(icons.resolve(icon_name)) self._icon.set_pixel_size(22) inner.append(self._icon) @@ -485,7 +487,7 @@ def _menu_row_button(icon_name, label, label_css=None): btn = Gtk.Button(hexpand=True) btn.add_css_class("flat") row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - row.append(Gtk.Image.new_from_icon_name(icon_name)) + row.append(Gtk.Image.new_from_icon_name(icons.resolve(icon_name))) lbl = Gtk.Label(label=label, xalign=0, hexpand=True) if label_css: lbl.add_css_class(label_css) @@ -582,7 +584,7 @@ def set_subtitle(self, subtitle): self._subtitle_lbl.set_visible(bool(subtitle)) def set_icon(self, icon_name): - self._icon.set_from_icon_name(icon_name) + self._icon.set_from_icon_name(icons.resolve(icon_name)) def set_empty(self, empty): """Mark the column as carrying nothing. @@ -702,13 +704,14 @@ def __init__(self, *, name, icon_name, has_level, removable=False, self.append(inner) if reorderable: - handle = Gtk.Image.new_from_icon_name("list-drag-handle-symbolic") + handle = Gtk.Image.new_from_icon_name( + icons.resolve("list-drag-handle-symbolic")) handle.set_pixel_size(14) handle.add_css_class("dim-label") handle.set_tooltip_text("Drag to reorder") inner.append(handle) - self._icon = Gtk.Image.new_from_icon_name(icon_name) + self._icon = Gtk.Image.new_from_icon_name(icons.resolve(icon_name)) self._icon.set_pixel_size(26) inner.append(self._icon) @@ -846,7 +849,7 @@ def set_name(self, name): self._name_lbl.set_tooltip_text(name) def set_icon(self, icon_name): - self._icon.set_from_icon_name(icon_name) + self._icon.set_from_icon_name(icons.resolve(icon_name)) def set_available(self, available, *, reason="Device not connected"): """Dim the row when the device behind it is gone. diff --git a/wavexlr/paths.py b/wavexlr/paths.py index f694cec..cc8f9b3 100644 --- a/wavexlr/paths.py +++ b/wavexlr/paths.py @@ -18,13 +18,33 @@ is not a fixed depth (lib/python3.13/site-packages, lib64/python3.13/site-packages, ...), so walk up until the expected subdirectory appears instead of counting levels. + +The walk alone is not enough, though, because that layout is a fiction: the +Makefile takes from the interpreter, as an absolute path, so it +does not move when PREFIX does. Installing with the Makefile's own default +PREFIX=/usr/local against a distribution whose site-packages is +/usr/lib/python3.N/site-packages puts the module under /usr and its data under +/usr/local, and no ancestor of the module is ever /usr/local. The walk comes up +empty and first-run setup dies on a rule it did install, just not where it +looked. So try the usual prefixes too once the walk has failed. """ import os +import sys _MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) _MAX_DEPTH = 8 +# Tried only after the walk above, so an install that is self-consistent still +# resolves against its own prefix first: the running interpreter's prefix, then +# the two that the Makefile and install.sh actually default to. +_FALLBACK_PREFIXES = ( + sys.prefix, + getattr(sys, "base_prefix", sys.prefix), + "/usr", + "/usr/local", +) + def _ancestors(): """This package's directory and its parents, nearest first.""" @@ -37,18 +57,35 @@ def _ancestors(): d = parent +def _prefixes(): + """Every prefix worth looking under, nearest first, without repeats. + + The filesystem root is not one of them. Walking up always arrives there + eventually, and on a merged-usr system /bin and /lib exist, so a root that + was never anybody's PREFIX would answer for every lookup and the + "prefers its own prefix" rule above would stop meaning anything. + """ + root = os.path.abspath(os.sep) + seen = set() + for d in list(_ancestors()) + list(_FALLBACK_PREFIXES): + if d and d != root and d not in seen: + seen.add(d) + yield d + + def data_file(*parts): """Return an installed data file's path, or None if it is not present. Checked in order: a source checkout, where the data directories sit beside the package rather than under share/; then /share/openwave for - every plausible prefix above this module. + every plausible prefix above this module; then the fallback prefixes, which + is what a PREFIX that does not contain site-packages needs. """ rel = os.path.join(*parts) candidates = [os.path.join(os.path.dirname(_MODULE_DIR), rel)] candidates += [ - os.path.join(d, "share", "openwave", rel) for d in _ancestors() + os.path.join(d, "share", "openwave", rel) for d in _prefixes() ] for candidate in candidates: @@ -62,9 +99,11 @@ def bin_file(name): Prefers the copy under this package's own prefix so a service unit keeps pointing at the install it was generated from, rather than whichever one - happens to be first on PATH later. + happens to be first on PATH later. Falls back to the same prefixes as + data_file, for the same reason: bin/ follows PREFIX and this module does + not have to. """ - for d in _ancestors(): + for d in _prefixes(): candidate = os.path.join(d, "bin", name) if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate From 078f9d82e2f513d414550a987d3b2ea6539965b9 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 11:56:21 -0500 Subject: [PATCH 51/99] Say whether the microphone is live, in an icon of our own The tray borrowed audio-input-microphone-symbolic and never changed it. IconName was a constant, ITEM_XML declared no NewIcon at all, and the plumbing ran one way -- on_mute went in, nothing came back -- so the icon could not have changed even if something had wanted it to. It said the same thing whether a Wave was open, muted or absent. What it should say is whether you are on air, and that is not the USB mute bit. There are two mutes on one microphone and they move independently: the hardware bit, which the button and the window's switch drive, and the PipeWire row mute, which group hand-over drives without touching the hardware at all. Hand-over is defined as moving the second one, so the two disagree routinely rather than exceptionally, and a tray reading only the hardware bit shows a live microphone while nothing is being captured. That is the one error this icon must not make: the only reason to look at it is to find out whether you are being heard, and it would be confidently wrong exactly when that matters. compute() takes the three facts that decide it and returns what to draw. It is a plain function above the D-Bus object because the rule is the part worth testing and the plumbing needs a session bus to exist; these are the first tests tray.py has had. Either mute shows muted, and the tooltip names which one, because the way out differs -- the hardware button will not clear a row mute, and somebody who has pressed it and seen nothing change has no other way to find out why. Before the first poll the state is "no device" rather than "live", since nothing is known then and live is the direction this must never guess in. The icons ship in hicolor instead of being borrowed from the theme. The previous commit fixed a row drawing a broken image because Breeze has no web-browser-symbolic; a tray icon is a worse place to make that assumption, and there is no theme name for "OpenWave" in any case. Three states: a microphone with sound arcs, the same with the arcs gone and a slash, and a hollow one for no device -- silence and absence being different things and worth telling apart at a glance. They carry KDE's current-color-scheme stylesheet, so Plasma recolours them to the panel foreground. The colour left in that stylesheet is a mid grey rather than the #232629 KDE ships, which is deliberate: when the desktop does replace it the value is irrelevant, and when it does not, grey is legible on a dark panel and #232629 is very nearly invisible on one. Checked by rendering the set against both a light and a dark ground rather than by assuming, which is also what showed the muted slash running through the stand and turning to mush at the size a tray actually draws. The desktop entry stops borrowing audio-input-microphone too, and the Makefile refreshes the hicolor cache after installing: GTK trusts that cache over the directory when it is present, so a stale one hides an icon that was just installed and looks exactly like the icon being wrong. Skipped for a staged build, where the packaging tool owns it. Fifteen tests, none of which need a session bus, a tray host or a Wave. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- Makefile | 16 +++ icons/openwave-attention-symbolic.svg | 9 ++ icons/openwave-muted-symbolic.svg | 10 ++ icons/openwave-symbolic.svg | 11 +++ icons/openwave.svg | 12 +++ tests/test_tray.py | 125 +++++++++++++++++++++++ wavexlr.desktop | 2 +- wavexlr/app.py | 40 ++++++++ wavexlr/tray.py | 136 ++++++++++++++++++++++++-- 9 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 icons/openwave-attention-symbolic.svg create mode 100644 icons/openwave-muted-symbolic.svg create mode 100644 icons/openwave-symbolic.svg create mode 100644 icons/openwave.svg create mode 100644 tests/test_tray.py diff --git a/Makefile b/Makefile index 0a8c3ab..d212c60 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ BINDIR = $(DESTDIR)$(PREFIX)/bin DATADIR = $(DESTDIR)$(PREFIX)/share APPDIR = $(DATADIR)/openwave DESKTOPDIR = $(DATADIR)/applications +ICONDIR = $(DATADIR)/icons/hicolor DOCDIR = $(DATADIR)/doc/openwave LICENSEDIR = $(DATADIR)/licenses/openwave @@ -26,8 +27,19 @@ install: check-prefix install -Dm644 openwave-autostart.desktop $(APPDIR)/openwave-autostart.desktop install -Dm644 wireplumber/51-openwave-wave-xlr.conf $(APPDIR)/wireplumber/51-openwave-wave-xlr.conf install -Dm644 pipewire/52-openwave-mixes.conf $(APPDIR)/pipewire/52-openwave-mixes.conf + install -Dm644 icons/openwave.svg $(ICONDIR)/scalable/apps/openwave.svg + install -Dm644 icons/openwave-symbolic.svg $(ICONDIR)/symbolic/apps/openwave-symbolic.svg + install -Dm644 icons/openwave-muted-symbolic.svg $(ICONDIR)/symbolic/apps/openwave-muted-symbolic.svg + install -Dm644 icons/openwave-attention-symbolic.svg $(ICONDIR)/symbolic/apps/openwave-attention-symbolic.svg install -Dm644 README.md $(DOCDIR)/README.md install -Dm644 LICENSE $(LICENSEDIR)/LICENSE +# hicolor keeps a cache, and GTK trusts it over the directory when it is +# there: a stale one hides an icon that was just installed, which looks +# exactly like the icon being wrong. Skipped for a staged build, where the +# packaging tool owns the cache. + @if [ -z "$(DESTDIR)" ] && command -v gtk-update-icon-cache >/dev/null 2>&1; then \ + gtk-update-icon-cache -qtf $(ICONDIR) 2>/dev/null || true; \ + fi uninstall: rm -rf $(DESTDIR)$(SITEPKG)/wavexlr @@ -36,6 +48,10 @@ uninstall: rm -f $(DESKTOPDIR)/openwave.desktop rm -rf $(APPDIR) rm -rf $(DOCDIR) + rm -f $(ICONDIR)/scalable/apps/openwave.svg + rm -f $(ICONDIR)/symbolic/apps/openwave-symbolic.svg + rm -f $(ICONDIR)/symbolic/apps/openwave-muted-symbolic.svg + rm -f $(ICONDIR)/symbolic/apps/openwave-attention-symbolic.svg rm -rf $(LICENSEDIR) # site-packages is chosen by the interpreter and is an absolute path: it does diff --git a/icons/openwave-attention-symbolic.svg b/icons/openwave-attention-symbolic.svg new file mode 100644 index 0000000..140fc3c --- /dev/null +++ b/icons/openwave-attention-symbolic.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/icons/openwave-muted-symbolic.svg b/icons/openwave-muted-symbolic.svg new file mode 100644 index 0000000..4b2cd68 --- /dev/null +++ b/icons/openwave-muted-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/icons/openwave-symbolic.svg b/icons/openwave-symbolic.svg new file mode 100644 index 0000000..5c755b2 --- /dev/null +++ b/icons/openwave-symbolic.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/icons/openwave.svg b/icons/openwave.svg new file mode 100644 index 0000000..e092284 --- /dev/null +++ b/icons/openwave.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/tests/test_tray.py b/tests/test_tray.py new file mode 100644 index 0000000..98d7f07 --- /dev/null +++ b/tests/test_tray.py @@ -0,0 +1,125 @@ +"""What the tray icon claims about the microphone. + +There are two mutes on one microphone and they move independently: the USB bit +that the hardware button and the window's switch drive, and the PipeWire row +mute that group hand-over drives without touching the hardware at all. A tray +reading only the first reports a live microphone while nothing is being +captured -- the one error this icon must not make, since being on air is the +only reason to look at it. + +The rule is a pure function so it can be tested without a session bus, a tray +host, or a Wave. +""" + +import unittest + +from wavexlr import tray +from wavexlr.app import WaveXLRWindow + + +class TheRule(unittest.TestCase): + """compute(): three facts in, what to draw out.""" + + def test_no_device_is_not_a_live_microphone(self): + state = tray.compute(connected=False, hardware_muted=False, + row_muted=False) + self.assertEqual(state["icon"], tray.ICON_ABSENT) + self.assertEqual(state["tooltip"], "No Wave connected") + + def test_muting_cannot_be_chosen_with_no_device(self): + """The menu item would do nothing; saying so beats it silently failing.""" + state = tray.compute(False, False, False) + self.assertFalse(state["mute_enabled"]) + + def test_a_connected_open_microphone_is_live(self): + state = tray.compute(True, False, False) + self.assertEqual(state["icon"], tray.ICON_LIVE) + self.assertEqual(state["tooltip"], "Live") + self.assertFalse(state["muted"]) + + def test_the_hardware_bit_mutes(self): + state = tray.compute(True, hardware_muted=True, row_muted=False) + self.assertEqual(state["icon"], tray.ICON_MUTED) + self.assertEqual(state["tooltip"], "Muted (hardware)") + + def test_the_row_mute_mutes_on_its_own(self): + """The regression: hardware says open, nothing is captured. + + This is what group hand-over leaves behind on the microphone it + handed away from, and reading the USB bit alone calls it live. + """ + state = tray.compute(True, hardware_muted=False, row_muted=True) + self.assertEqual(state["icon"], tray.ICON_MUTED) + self.assertTrue(state["muted"]) + self.assertEqual(state["tooltip"], "Muted (matrix row)") + + def test_the_two_mutes_are_told_apart(self): + """The way out differs, so naming the wrong one strands the user.""" + self.assertEqual(tray.compute(True, True, True)["tooltip"], + "Muted (hardware and matrix)") + + def test_the_menu_offers_the_action_not_the_state(self): + self.assertEqual(tray.compute(True, False, False)["mute_label"], + "Mute Mic") + self.assertEqual(tray.compute(True, True, False)["mute_label"], + "Unmute Mic") + + +class Announcing(unittest.TestCase): + """set_state(): hosts redraw on every signal, so only real changes go out.""" + + def setUp(self): + self.tray = tray.TrayIcon() + + def test_it_starts_out_assuming_no_device(self): + """Before the first poll nothing is known, and 'live' would be a guess.""" + self.assertEqual(self.tray._state["icon"], tray.ICON_ABSENT) + + def test_a_change_is_reported(self): + self.assertTrue(self.tray.set_state(True, False, False)) + self.assertEqual(self.tray._state["icon"], tray.ICON_LIVE) + + def test_an_unchanged_state_is_not_reported(self): + self.tray.set_state(True, False, False) + self.assertFalse(self.tray.set_state(True, False, False)) + + def test_the_menu_label_follows_the_state(self): + self.tray.set_state(True, True, False) + self.assertEqual(self.tray._menu_items[2]["label"].unpack(), + "Unmute Mic") + + +class CaptureRows(unittest.TestCase): + """capture_rows_muted(): the row half of the answer, read off the sources.""" + + def muted(self, sources): + stub = type("W", (), {})() + stub._sources = sources + return WaveXLRWindow.capture_rows_muted(stub) + + def test_no_capture_rows_is_not_muted(self): + """Nothing to be silenced by is not the same as silenced.""" + self.assertFalse(self.muted({"a": {"name": "Game"}})) + + def test_one_live_row_is_enough(self): + self.assertFalse(self.muted({ + "a": {"node_name": "alsa_in.one", "muted": True}, + "b": {"node_name": "alsa_in.two", "muted": False}, + })) + + def test_every_row_muted_is_muted(self): + self.assertTrue(self.muted({ + "a": {"node_name": "alsa_in.one", "muted": True}, + "b": {"node_name": "alsa_in.two", "muted": True}, + })) + + def test_application_rows_are_not_capture_rows(self): + """A muted Music row says nothing about the microphone.""" + self.assertFalse(self.muted({ + "music": {"muted": True}, + "mic": {"node_name": "alsa_in.one", "muted": False}, + })) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr.desktop b/wavexlr.desktop index 86c48f4..f08bdc8 100644 --- a/wavexlr.desktop +++ b/wavexlr.desktop @@ -2,6 +2,6 @@ Name=OpenWave Comment=Elgato Wave Control for Linux Exec=openwave -Icon=audio-input-microphone +Icon=openwave Type=Application Categories=Audio;Settings; diff --git a/wavexlr/app.py b/wavexlr/app.py index 7960469..1685f8b 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -607,6 +607,7 @@ def _on_poll_error(self, e): self.status_label.add_css_class("dim-label") self.dev.disconnect() self._stop_polling() + self._notify_tray() def _apply_profile(self, profile): """Adapt the UI to the connected device model.""" @@ -651,12 +652,32 @@ def _apply_state(self, state): self.mix_label.set_label(f"{state['monitor_mix'] / 256:.0f}%") # Gain and mute live in the sidebar; no matrix row mirrors them. self._updating_ui = False + self._notify_tray() + + def capture_rows_muted(self): + """True when no capture row is live. + + The other half of being on air. Group hand-over mutes a row and + touches no hardware, so with two microphones grouped the one that is + not live is muted here and nowhere else. With no capture rows at all + there is nothing to be silenced by, which is not the same as muted. + """ + rows = [s for s in self._sources.values() if s.get("node_name")] + if not rows: + return False + return all(s.get("muted", False) for s in rows) + + def _notify_tray(self): + app = self.get_application() + if app is not None: + app.refresh_tray() def _on_usb_error(self, e): self.status_label.set_label("Disconnected") self.status_label.add_css_class("dim-label") self.dev.disconnect() self._stop_polling() + self._notify_tray() def _on_mute_changed(self, row, _pspec): if self._updating_ui or not self.dev.connected: @@ -1256,6 +1277,7 @@ def _on_source_mute_toggled(self, _cell, muted, source_id): if not muted: self._enforce_exclusive_group(source_id) sources_module.save(self._sources) + self._notify_tray() def _on_group_sources_clicked(self, _matrix, dragged_id, target_id): """Put the dragged source in the target's group. @@ -1350,6 +1372,7 @@ def toggle_source_mute(self, source_id): if not muted: self._enforce_exclusive_group(source_id) sources_module.save(self._sources) + self._notify_tray() return muted def set_cell_volume(self, source_id, mix_id, volume): @@ -1828,6 +1851,23 @@ def _setup_tray(self): self._tray = tray # Keep app alive when window is hidden self.hold() + self.refresh_tray() + + def refresh_tray(self): + """Push what the microphone is really doing to the tray icon. + + Cheap to call from anywhere either mute can move: set_state does + nothing at all unless the computed state actually differs. + """ + if not self._tray or not self._window: + return + window = self._window + state = window._last_state or {} + self._tray.set_state( + bool(window.dev.connected), + bool(state.get("mute", False)), + window.capture_rows_muted(), + ) def _toggle_mute(self): if self._window and self._window.dev.connected: diff --git a/wavexlr/tray.py b/wavexlr/tray.py index 228a0dd..b91d577 100644 --- a/wavexlr/tray.py +++ b/wavexlr/tray.py @@ -1,5 +1,7 @@ """StatusNotifierItem tray icon via D-Bus (no GTK3 dependency).""" +import logging + from gi.repository import Gio, GLib ITEM_XML = """ @@ -25,6 +27,11 @@ + + + + + """ @@ -85,6 +92,67 @@ """ +# Shipped in hicolor by the Makefile rather than borrowed from the active +# theme, so the tray does not depend on the theme having a microphone glyph -- +# the same assumption that left the Browser row drawing a broken image under +# Breeze. +ICON_LIVE = "openwave-symbolic" +ICON_MUTED = "openwave-muted-symbolic" +ICON_ABSENT = "openwave-attention-symbolic" + + +def compute(connected, hardware_muted, row_muted): + """What the tray should show, from the three facts that decide it. + + A pure function, kept apart from the D-Bus object because the rule is the + part worth testing and the plumbing needs a session bus to exist. + + There are two mutes on one microphone and they are independent. The USB + bit is what the hardware button and the mute switch in the window move. + The row mute is a PipeWire one on the source row, and handing a microphone + group over moves it without touching the hardware at all -- that is what + hand-over is. So the states disagree routinely rather than exceptionally, + and a tray that reads only the USB bit reports a live microphone while + nothing is being captured. That is the worst thing this icon can do: the + only reason to look at it is to find out whether you are on air, and it + would be confidently wrong exactly when the answer matters. + + Either mute means not captured, so either one shows muted. The tooltip + says which, because the way out differs -- the hardware button will not + clear a row mute, and a user who has pressed it and seen nothing change + has no other way to find out why. + """ + if not connected: + return { + "icon": ICON_ABSENT, + "status": "Active", + "tooltip": "No Wave connected", + "mute_label": "Mute Mic", + "mute_enabled": False, + "muted": False, + } + + muted = bool(hardware_muted or row_muted) + if muted: + if hardware_muted and row_muted: + detail = "Muted (hardware and matrix)" + elif hardware_muted: + detail = "Muted (hardware)" + else: + detail = "Muted (matrix row)" + else: + detail = "Live" + + return { + "icon": ICON_MUTED if muted else ICON_LIVE, + "status": "Active", + "tooltip": detail, + "mute_label": "Unmute Mic" if muted else "Mute Mic", + "mute_enabled": True, + "muted": muted, + } + + class TrayIcon: """Minimal StatusNotifierItem tray icon.""" @@ -103,6 +171,10 @@ def __init__(self, on_activate=None, on_mute=None, on_quit=None, self._name_id = None self._revision = 1 self._menu_items = {} # id -> properties dict + # Nothing is known before the first poll, and "no device" is the + # honest reading of that -- not "live", which would be a guess in the + # one direction this icon must never guess. + self._state = compute(False, False, False) @staticmethod def host_available(bus=None): @@ -182,6 +254,54 @@ def register(self): # registration; whatever the reason, nothing will draw us. return False + def set_state(self, connected, hardware_muted=False, row_muted=False): + """Show what the microphone is actually doing. Returns True if it moved. + + Announced only on a real change: the poll behind this runs at 10 Hz, + and a host redraws on every NewIcon it is handed. + """ + new = compute(connected, hardware_muted, row_muted) + if new == self._state: + return False + + icon_changed = new["icon"] != self._state["icon"] + tooltip_changed = new["tooltip"] != self._state["tooltip"] + menu_changed = ( + new["mute_label"] != self._state["mute_label"] + or new["mute_enabled"] != self._state["mute_enabled"] + ) + self._state = new + self._build_menu_items() + + if self._bus is None: + return True # not registered yet; the values are already right + if icon_changed: + self._emit_item("NewIcon", None) + if tooltip_changed: + self._emit_item("NewToolTip", None) + if menu_changed: + self._emit_menu_properties(2) + return True + + def _emit_item(self, name, params): + """A host that has gone away must not take the application with it.""" + try: + self._bus.emit_signal( + None, "/StatusNotifierItem", "org.kde.StatusNotifierItem", + name, params) + except GLib.Error as e: + logging.debug("tray: could not emit %s: %s", name, e) + + def _emit_menu_properties(self, item_id): + props = self._menu_items.get(item_id, {}) + try: + self._bus.emit_signal( + None, "/MenuBar", "com.canonical.dbusmenu", + "ItemsPropertiesUpdated", + GLib.Variant("(a(ia{sv})a(ias))", ([(item_id, props)], []))) + except GLib.Error as e: + logging.debug("tray: could not emit ItemsPropertiesUpdated: %s", e) + def _on_item_call(self, conn, sender, path, iface, method, params, invocation): if method == "Activate": if self._on_activate: @@ -193,9 +313,11 @@ def _on_item_get_property(self, conn, sender, path, iface, prop): "Category": GLib.Variant("s", "Hardware"), "Id": GLib.Variant("s", "openwave"), "Title": GLib.Variant("s", "OpenWave"), - "Status": GLib.Variant("s", "Active"), - "IconName": GLib.Variant("s", "audio-input-microphone-symbolic"), - "ToolTip": GLib.Variant("(sa(iiay)ss)", ("", [], "OpenWave", "Elgato Wave Control")), + "Status": GLib.Variant("s", self._state["status"]), + "IconName": GLib.Variant("s", self._state["icon"]), + "ToolTip": GLib.Variant( + "(sa(iiay)ss)", + ("", [], "OpenWave", self._state["tooltip"])), "Menu": GLib.Variant("o", "/MenuBar"), "ItemIsMenu": GLib.Variant("b", False), } @@ -209,13 +331,13 @@ def _build_menu_items(self): "label": GLib.Variant("s", "Open OpenWave"), "visible": GLib.Variant("b", True), "enabled": GLib.Variant("b", True), - "icon-name": GLib.Variant("s", "audio-input-microphone-symbolic"), + "icon-name": GLib.Variant("s", ICON_LIVE), }, 2: { - "label": GLib.Variant("s", "Mute Mic"), + "label": GLib.Variant("s", self._state["mute_label"]), "visible": GLib.Variant("b", True), - "enabled": GLib.Variant("b", True), - "icon-name": GLib.Variant("s", "microphone-sensitivity-muted-symbolic"), + "enabled": GLib.Variant("b", self._state["mute_enabled"]), + "icon-name": GLib.Variant("s", ICON_MUTED), }, 3: { "type": GLib.Variant("s", "separator"), From 2bc471845c217278b6b7bcb308f28e44ae650aab Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:13:50 -0500 Subject: [PATCH 52/99] Keep a mix master from reverting: two ways the saved value lost The mix masters reverted on a fresh install and threaten to on every reboot, for two different reasons, both found by watching a real revert happen rather than reading for one. The first is a hole in c0298a3's gate. Restore was gated on the mix definitions being loaded, but the definitions are a local JSON file and the sinks are made by the PipeWire daemon: having one says nothing about the other. On a first run the config defining the sinks has only just been written, so restore runs before they exist; pactl fails, silently -- _run_quiet does not look at the return code -- the gate opens on a restore that reached nothing, and the next observation tick persists the unity the sinks then come up at. Exactly the destruction the gate exists to prevent, through the gap between loading a file and the daemon acting on one. A PipeWire restart reopens the same gap. Restore now opens the gate only when every sink it means to write to is actually present -- a sink with nothing remembered has nothing to lose and does not hold the gate, since a first run must still start observing -- and the poll tick retries while the gate is shut, so a sink that arrives late is restored when it arrives. The second contradicts the premise c0298a3 was built on. WirePlumber does restore these sinks on some setups: its stream-properties state carries Audio/Sink entries for them by node.name, and when a sink reappears it re-applies its own last-seen channelVolumes -- racing the restore OpenWave does from mixes.json, one value winning on one boot and the other on the next. Seen live on a machine whose stored value was a stale 92%. The generated sink config now sets state.restore-props = false: the master is OpenWave's to remember, and WirePlumber leaving it alone is what makes mixes.json the single authority instead of one of two. The Restoring tests now say which sinks exist instead of inheriting whatever machine they run on, and the file's trailing test class was above a mid-file unittest.main() guard, where running the file directly never collected it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/test_config_render.py | 9 +++++++ tests/test_mix_volumes.py | 52 ++++++++++++++++++++++++++++++++++--- wavexlr/app.py | 6 +++++ wavexlr/mixer.py | 22 ++++++++++++++++ wavexlr/setup.py | 8 ++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/tests/test_config_render.py b/tests/test_config_render.py index e9f3d1f..f57cbdb 100644 --- a/tests/test_config_render.py +++ b/tests/test_config_render.py @@ -43,6 +43,15 @@ def test_every_sink_lingers_and_exposes_a_post_volume_monitor(self): self.assertEqual( self.rendered.count("monitor.channel-volumes = true"), 3) + def test_every_sink_opts_out_of_wireplumber_restore(self): + """WirePlumber's restore-stream tracks these sinks on some setups and + re-applies its own last-seen level when one reappears, racing the + restore OpenWave does from mixes.json -- observed as a master + reverting to a stale value on reboot. The sink must say the property + is not WirePlumber's to restore.""" + self.assertEqual( + self.rendered.count("state.restore-props = false"), 3) + def test_it_is_marked_generated(self): self.assertIn(setup.GENERATED_MARKER, self.rendered) diff --git a/tests/test_mix_volumes.py b/tests/test_mix_volumes.py index f77d2d8..4a22bf2 100644 --- a/tests/test_mix_volumes.py +++ b/tests/test_mix_volumes.py @@ -98,11 +98,18 @@ def setUp(self): lambda s, v: self.applied.append(("volume", s, round(v, 3))) mixer_mod._pactl_set_sink_mute = \ lambda s, m: self.applied.append(("mute", s, m)) + self._live = mixer_mod._pactl_sink_volumes + mixer_mod._pactl_sink_volumes = lambda: { + "openwave_personal_mix": (1.0, False), + "openwave_chat_mix": (1.0, False), + } self.mixer = bare_mixer(_mixes=dict(MIXES)) + self.mixer._volumes_restored = False def tearDown(self): mixer_mod._pactl_set_sink_volume = self._vol mixer_mod._pactl_set_sink_mute = self._mute + mixer_mod._pactl_sink_volumes = self._live self._ctx.__exit__(None, None, None) def test_it_puts_back_what_was_remembered(self): @@ -201,10 +208,6 @@ def test_junk_is_not_an_exception(self): self.assertIsInstance(self._parse(payload), dict) -if __name__ == "__main__": - unittest.main() - - class TheBootRace(unittest.TestCase): """The one that matters: at boot the sinks exist at unity before OpenWave does. An observation that lands before the restore persists that @@ -257,3 +260,44 @@ def test_restoring_with_no_mixes_leaves_the_gate_shut(self): self.assertFalse(self.mixer.restore_mix_volumes()) self.mixer.observe_mix_volumes() self.assertEqual(self.mixer.mix_volume("personal"), (0.62, False)) + + def test_the_gate_stays_shut_until_the_sinks_exist(self): + """The definitions can be loaded while the sinks still are not. + + First run writes the PipeWire config, so the daemon creates the mix + sinks after OpenWave is already running; the same gap opens whenever + PipeWire is restarted under it. The restore writes into that gap and + pactl fails, silently -- _run_quiet does not even look at the return + code -- so the gate would open on a restore that did nothing, and the + next tick persists the unity the sinks then come up at. + """ + mixer_mod._pactl_sink_volumes = lambda: {} # not created yet + self.mixer.remember_mix_volume("personal", 0.62, False) + self.assertFalse(self.mixer.restore_mix_volumes()) + + # a moment later the daemon creates them, at unity + mixer_mod._pactl_sink_volumes = lambda: { + "openwave_personal_mix": (1.0, False)} + self.mixer.observe_mix_volumes() + self.assertEqual(self.mixer.mix_volume("personal"), (0.62, False)) + + def test_a_late_sink_is_restored_when_it_does_arrive(self): + """Shutting the gate is only right if something reopens it.""" + mixer_mod._pactl_sink_volumes = lambda: {} + self.mixer.remember_mix_volume("personal", 0.62, False) + self.assertFalse(self.mixer.restore_mix_volumes()) + + mixer_mod._pactl_sink_volumes = lambda: { + "openwave_personal_mix": (1.0, False)} + self.assertTrue(self.mixer.restore_mix_volumes()) + self.assertIn(("openwave_personal_mix", 0.62), self.applied) + + def test_nothing_remembered_does_not_wait_for_a_sink(self): + """A first run has nothing to protect, and must still start + observing -- otherwise the first level a user sets is never saved.""" + mixer_mod._pactl_sink_volumes = lambda: {} + self.assertTrue(self.mixer.restore_mix_volumes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index 1685f8b..da881fc 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -967,6 +967,12 @@ def _start_stream_poll(self): def _stream_poll_tick(self): self.mixer.poll_streams() + if not self.mixer.volumes_restored: + # _do_start restores once, and the mix sinks may not have existed + # yet when it did -- first run creates them, and a PipeWire + # restart recreates them. Retrying here is what reopens the gate; + # without it the masters stay at whatever the daemon made them. + self.mixer.restore_mix_volumes() self.mixer.observe_mix_volumes() self._device_poll_countdown -= 1 check_devices = self._device_poll_countdown <= 0 diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index a54fbe0..92b03cf 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1014,6 +1014,11 @@ def remember_mix_volume(self, mix_id, volume, muted): self._save_state() return True + @property + def volumes_restored(self): + """True once the masters have been put back and observing is safe.""" + return self._volumes_restored + def restore_mix_volumes(self): """Put the mix masters back to what they were. Returns True if done. @@ -1029,6 +1034,23 @@ def restore_mix_volumes(self): # shut, so no observation can run and persist the unity the # daemon just created the sinks at. return False + # Having the definitions is not the same as having the sinks. First + # run writes the PipeWire configuration, so the daemon creates them + # after OpenWave is already up, and a PipeWire restart reopens the + # same gap. Writing into it fails silently -- _run_quiet does not look + # at the return code -- so a restore that reached nothing would open + # the gate anyway and the next tick would persist the unity the sinks + # are about to appear at. Only a sink we actually mean to put a value + # back onto can hold the gate shut: one with nothing remembered has + # nothing to lose, and waiting on it would mean a first run never + # starts observing at all. + live = _pactl_sink_volumes() + for mix_id, mix in list(self._mixes.items()): + sink = mix.get("sink") + if not sink or self.mix_volume(mix_id) is None: + continue + if sink not in live: + return False for mix_id, mix in list(self._mixes.items()): sink = mix.get("sink") remembered = self.mix_volume(mix_id) diff --git a/wavexlr/setup.py b/wavexlr/setup.py index 699e65c..a541225 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -187,6 +187,14 @@ def render_mixes_conf(mixes): " audio.position = [ FL FR ]\n" " object.linger = true\n" " monitor.channel-volumes = true\n" + # A mix master is OpenWave's to remember. WirePlumber's + # restore-stream tracks these sinks on some setups (its + # stream-properties carries Audio/Sink entries for them) and + # re-applies its own last-seen level when the sink reappears -- + # racing, and usually beating, the restore OpenWave does from + # mixes.json. One value wins on one boot and the other on the + # next, which reads as levels reverting at random. + " state.restore-props = false\n" " }\n" " }\n" ) From 7758ad50bc816548343fdc264141f372fd616b89 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:34:10 -0500 Subject: [PATCH 53/99] Stop the test suite from wiping the user's mixing matrix Settings kept vanishing -- cells gone, masters back at unity, the monitored output forgotten -- looking exactly like the application losing state at random. It was not the application. RoutingWithoutAWave built a bare mixer without temp_config(), so its set_cell calls persisted synchronously to the real ~/.config/openwave/mixes.json, from an empty state: the first save replaced the user's whole matrix with the test's fixtures, and every run of the suite did it again. The survivor that made the wipes look partial was planted, not spared -- the 55% Music cell in every post-wipe screenshot is this file's fixture value. The masters going to unity was the same write: it dropped the volumes key, and observation then recorded the live sinks as they stood. Found by tracing, after reading ran out: nothing in the application deletes a cell outside remove_source and remove_mix, the saves are atomic, the process is single-instance, and shutdown does not save at all. Every save now appends what it wrote and who asked to write-trace.log, and the first trace of a wipe carried unittest frames. Three layers, because each would have been enough and none existed: - The class gets the temp_config() its siblings already had. - tests/__init__.py redirects every config path into a throwaway directory at package import, before any test module loads -- the seatbelt for the next test that forgets. The suite now passes with the real configuration byte-identical before and after, which is the test that would have caught this on the day it was written. - _save_state keeps mixes.json.pre-wipe before any save that would discard most of the cells on disk: whoever writes from stale or empty state next, the user's matrix survives it and the trace names them. The trace and the tripwire stay in the program. This file has now been emptied by three different authors -- a prefix that hid the data files, a restore racing the sinks, and the suite itself -- and a state file with that history has earned a flight recorder. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/__init__.py | 27 ++++++++++++++ tests/test_no_elgato.py | 9 +++++ wavexlr/mixer.py | 79 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..b247bf1 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,27 @@ +"""Every test runs against a throwaway config, whether it asked to or not. + +One test class built a bare mixer without temp_config(), and its set_cell +calls therefore rewrote the real ~/.config/openwave/mixes.json -- from an +empty state, so one write replaced the user's whole matrix with the test's +fixtures. It looked like the application losing settings at random, because +the wipe happened whenever the suite ran, and it left behind a plausible +55% Music cell that was actually test data. + +temp_config() remains the right tool inside a test; this is the seatbelt for +the test that forgets it. Redirected here, at package import, before any +test module loads, so there is no ordering to get wrong. +""" + +import atexit +import os +import tempfile + +from wavexlr import mixer, mixes, sources + +_SANDBOX = tempfile.TemporaryDirectory(prefix="openwave-tests-") +atexit.register(_SANDBOX.cleanup) + +sources.CONFIG_PATH = os.path.join(_SANDBOX.name, "sources.json") +mixes.CONFIG_PATH = os.path.join(_SANDBOX.name, "mixdefs.json") +mixer.CONFIG_PATH = os.path.join(_SANDBOX.name, "mixes.json") +mixer.Mixer._TRACE_PATH = os.path.join(_SANDBOX.name, "write-trace.log") diff --git a/tests/test_no_elgato.py b/tests/test_no_elgato.py index c76bf59..1d8478a 100644 --- a/tests/test_no_elgato.py +++ b/tests/test_no_elgato.py @@ -64,6 +64,15 @@ class RoutingWithoutAWave(unittest.TestCase): ARCTIS = "alsa_input.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.mono-fallback" + def setUp(self): + # set_cell persists synchronously; without this it persisted to the + # real user configuration, and the first save wiped it. + self._ctx = temp_config() + self._ctx.__enter__() + + def tearDown(self): + self._ctx.__exit__(None, None, None) + def _mixer(self): mx = bare_mixer() mx._sources = { diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 92b03cf..775fbcf 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -17,6 +17,7 @@ import signal import re import subprocess +import sys import threading import time from threading import Event, Lock @@ -650,10 +651,86 @@ def _migrate_state(self): def _save_state(self): os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + self._preserve_if_wiping() tmp = CONFIG_PATH + ".tmp" with open(tmp, "w") as f: json.dump(self._state, f, indent=2) os.replace(tmp, CONFIG_PATH) + self._trace_save() + + def _preserve_if_wiping(self): + """Keep a copy of the on-disk state before a save that would gut it. + + Every save rewrites the file whole from this instance's memory, so an + instance holding stale or empty state destroys the good copy in one + write -- which has happened, twice, and the second time took a + rebuilt six-cell matrix with it. Until the writer is caught, a save + about to discard most of the cells on disk sets the evidence aside + first: the user's matrix survives as mixes.json.pre-wipe and the + trace records that it happened. A one-cell difference is someone + deleting a row; most-of-them at once is nobody's edit. + """ + try: + with open(CONFIG_PATH) as f: + on_disk = json.load(f) + if not isinstance(on_disk, dict): + return + disk_cells = {k for k in on_disk if "." in k} + mem_cells = {k for k in self._state if "." in k} + lost = disk_cells - mem_cells + if len(lost) >= 2 and len(lost) > len(disk_cells) // 2: + import shutil + shutil.copy2(CONFIG_PATH, CONFIG_PATH + ".pre-wipe") + self._trace_note( + f"PRE-WIPE PRESERVED: about to drop {sorted(lost)}") + except (OSError, ValueError): + return + + # Cells have now vanished from this file twice with no code path found + # that deletes them: nothing but remove_source and remove_mix removes a + # cell, the saves are atomic, the application is single-instance -- and + # both wipes left exactly the cells whose loopbacks were live. Every + # explanation from reading has run out, so every save records what it + # wrote and who asked, and the next wipe names its author instead of + # being reconstructed from screenshots. The old machine carried the same + # log for the same reason; this time it is part of the program. + _TRACE_PATH = CONFIG_PATH.replace("mixes.json", "write-trace.log") + _TRACE_LIMIT = 256 * 1024 + + def _trace_note(self, text): + try: + with open(self._TRACE_PATH, "a") as t: + t.write(f"{time.strftime('%H:%M:%S')} {text}\n") + except Exception: + pass + + def _trace_save(self): + try: + cells = { + k: round(float(v.get("volume", 0.0)), 2) + for k, v in self._state.items() + if "." in k and isinstance(v, dict) + } + frames = [] + f = sys._getframe(2) # skip _trace_save and _save_state + for _ in range(6): + if f is None: + break + frames.append(f"{f.f_code.co_name}:{f.f_lineno}") + f = f.f_back + stamp = time.strftime("%H:%M:%S") + line = (f"{stamp} CELLS {cells}\n" + f" {' <- '.join(frames)}\n") + try: + if os.path.getsize(self._TRACE_PATH) > self._TRACE_LIMIT: + os.replace(self._TRACE_PATH, self._TRACE_PATH + ".1") + except OSError: + pass + with open(self._TRACE_PATH, "a") as t: + t.write(line) + except Exception: + # The trace exists to explain failures, not to cause any. + pass def get_cell(self, source_id, mix_id): return self._state.get( @@ -930,6 +1007,7 @@ def _mix_sink(self, mix_id): def remove_source(self, source_id): """Forget persisted cells now; tear down loopbacks on worker.""" + self._trace_note(f"remove_source({source_id})") with self._lock: prefix = f"{source_id}." for cell_key in [k for k in self._state if k.startswith(prefix)]: @@ -949,6 +1027,7 @@ def remove_mix(self, mix_id): the worker needs it to destroy the live node and _mix_sink() would already return None by the time the task runs. """ + self._trace_note(f"remove_mix({mix_id})") with self._lock: sink = self._mix_sink(mix_id) # Cell keys are exactly "." — split rather than match a From ad31c171c4464544c530488e33b97af636e5a4be Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:38:02 -0500 Subject: [PATCH 54/99] Title picker rows with the app's real name, not the bridge's The Add Source picker titles rows with raw application.name, so a Java app reads "ALSA plug-in [java]" and an Electron app reads "Chromium" -- the toolkit's name, not the program the user is looking for. Ported from CryoByte33/openwave, which splits the two correctly: app_name stays the stable match key, display_name is a label resolved from the process binary, and failing that from the owning X11 window by matching the stream's application.process.id to _NET_WM_PID. The lookup is lazy in both directions -- a non-generic name never consults the binary, and Xlib is imported only when a generic stream has no usable binary -- so the common path never touches X11 at all, and every failure path falls back to the raw name. python-xlib is strictly optional; native-Wayland apps have no X11 window and usually report a sane name anyway. The one subtlety is what counts as generic, and Cryo hit it twice before getting it right: an app whose name simply matches its binary (Zen/zen, Discord/discord) is the normal good case, not a generic name. Treating it as generic sent it through the window lookup, where a Flatpak's namespaced PID can collide with another sandbox's window -- Zen showed as "Bolt Launcher". The rule and that regression are both under test. The match key is deliberately untouched: _stream_identities() and claim_streams() still compare exact application.name, a row bound by its friendly title captures exactly what it did before, and a test asserts the enrichment never writes app_name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- PKGBUILD | 1 + README.md | 3 ++ flake.nix | 2 +- tests/test_names.py | 89 +++++++++++++++++++++++++++++++++++ wavexlr/mixer.py | 71 ++++++++++++++++++++++++++++ wavexlr/sourcedialog.py | 6 ++- wavexlr/wmnames.py | 101 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 tests/test_names.py create mode 100644 wavexlr/wmnames.py diff --git a/PKGBUILD b/PKGBUILD index 010d2e1..67f36ea 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -7,6 +7,7 @@ arch=('any') url="https://github.com/rikkichy/openwave" license=('MIT') depends=('python' 'python-gobject' 'gtk4' 'libadwaita' 'libusb' 'pipewire') +optdepends=('python-xlib: friendly app names in the Add Source picker') source=("$pkgname-$pkgver.tar.gz::https://github.com/rikkichy/openwave/archive/refs/tags/v$pkgver.tar.gz") sha256sums=('SKIP') diff --git a/README.md b/README.md index b3b2bff..7e221c3 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,9 @@ sudo make -C /path/to/openwave uninstall PREFIX=/usr/local - GTK4, libadwaita - PipeWire (for audio capture fix) - libusb 1.0 +- python-xlib *(optional)* — friendlier app names in the Add Source picker + for X11/XWayland apps that report a generic PipeWire name ("ALSA plug-in + [java]"); without it those rows fall back to the raw name ## Usage diff --git a/flake.nix b/flake.nix index f90f0dc..46da6ef 100644 --- a/flake.nix +++ b/flake.nix @@ -13,7 +13,7 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; - pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ]); + pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.xlib ]); sitePkgs = pkgs.python3.sitePackages; # "lib/python3.X/site-packages" usbLibs = pkgs.lib.makeLibraryPath [ pkgs.libusb1 ]; in diff --git a/tests/test_names.py b/tests/test_names.py new file mode 100644 index 0000000..b8512c3 --- /dev/null +++ b/tests/test_names.py @@ -0,0 +1,89 @@ +"""Friendly names for the Add Source picker, without touching the match key. + +app_name is what claim_streams() matches on and must stay exact; display_name +exists only so a Java app reads "RuneLite" instead of "ALSA plug-in [java]" in +the picker. The rules are pure functions, tested as such. +""" + +import unittest + +from wavexlr.mixer import _binary_name, _is_generic +from wavexlr.wmnames import _pick_name + + +class WhatCountsAsGeneric(unittest.TestCase): + def test_bridge_names_are_generic(self): + for name in ("ALSA plug-in [java]", "alsa-playback", "PulseAudio"): + self.assertTrue(_is_generic(name, ""), name) + + def test_toolkit_defaults_are_generic(self): + for name in ("Chromium", "electron", "unknown", "java"): + self.assertTrue(_is_generic(name, ""), name) + + def test_a_real_app_name_is_not(self): + for name in ("Spotify", "Discord", "Firefox"): + self.assertFalse(_is_generic(name, ""), name) + + def test_a_name_matching_its_binary_is_not_generic(self): + """The regression Cryo hit twice: Zen/zen is the normal good case. + + Sending it through the window lookup let a Flatpak's namespaced PID + collide with another sandbox's window, and Zen showed as "Bolt + Launcher". + """ + self.assertFalse(_is_generic("Zen", "/app/bin/zen")) + self.assertFalse(_is_generic("Discord", "discord")) + + def test_empty_is_generic(self): + self.assertTrue(_is_generic("", "")) + self.assertTrue(_is_generic(None, None)) + + +class NameFromBinary(unittest.TestCase): + def test_a_meaningful_binary_names_the_app(self): + self.assertEqual(_binary_name("/usr/bin/cider", "Chromium"), "cider") + + def test_a_runtime_binary_says_nothing(self): + for b in ("java", "/usr/bin/python3", "wine64", "node"): + self.assertIsNone(_binary_name(b, "ALSA plug-in"), b) + + def test_a_binary_echoing_the_name_adds_nothing(self): + self.assertIsNone(_binary_name("spotify", "Spotify")) + + def test_no_binary_is_no_name(self): + self.assertIsNone(_binary_name("", "whatever")) + self.assertIsNone(_binary_name(None, "whatever")) + + +class PickingTheWindowName(unittest.TestCase): + def test_a_clean_class_beats_the_volatile_title(self): + """WM_CLASS is the app identity; _NET_WM_NAME is the tab title.""" + self.assertEqual( + _pick_name("Chromium", "Funny Cat Video - YouTube"), "Chromium") + + def test_a_reverse_dns_class_falls_back_to_the_title(self): + self.assertEqual( + _pick_name("net-runelite-client-RuneLite", "RuneLite"), "RuneLite") + self.assertEqual( + _pick_name("com.adamcake.Bolt", "Bolt Launcher"), "Bolt Launcher") + + def test_nothing_useful_returns_what_there_is(self): + self.assertEqual(_pick_name("", "Title"), "Title") + self.assertEqual(_pick_name("a.b", ""), "a.b") + self.assertEqual(_pick_name("", ""), "") + + +class MatchKeyIsUntouched(unittest.TestCase): + def test_enrichment_never_rewrites_app_name(self): + """display_name is additive; the key claim_streams matches on is not + modified by any of this. Guarded here as a rule, since the routing + depends on exact equality.""" + from wavexlr import mixer + import inspect + src = inspect.getsource(mixer.list_audio_streams) + self.assertIn('stream["display_name"] =', src) + self.assertNotIn('stream["app_name"] =', src) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 775fbcf..8bfe29f 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -393,6 +393,59 @@ def default_sink_name(): return _default_sink_name() +# ----- friendly display names ------------------------------------------------ +# app_name is the stable MATCH KEY and must never be enriched; display_name is +# a LABEL for the Add Source picker. Ported from CryoByte33/openwave, which +# split the two the same way. + +_GENERIC_PREFIXES = ("ALSA plug-in", "alsa-playback", "PulseAudio") +# Engine/toolkit defaults that aren't the real app — Electron apps commonly +# report "Chromium" even though the binary is the actual app (e.g. "Cider"). +_GENERIC_NAMES = {"chromium", "electron", "unknown"} +# Binaries that are runtimes/launchers, not the app itself — their name tells +# us nothing, so for these we fall through to the owning X11 window instead. +# Doubles as a set of generic *names*: an app reporting +# application.name="java" is just as unhelpful as the binary "java". +_RUNTIME_BINARIES = { + "java", "electron", "chromium", "chromium-browser", "chrome", + "google-chrome", "wine", "wine64", "wine-preloader", "python", "python3", + "mono", "node", "nw", "sh", "bash", +} + + +def _is_generic(app_name, binary): + """True when application.name is an unhelpful toolkit/bridge/runtime label + rather than the real app — only these get enriched (binary, then X11 + window). + + An app whose name simply matches its binary (Zen/zen, Discord/discord) is + NOT generic: that is the normal, good case. Treating it as generic sent it + through the window lookup, where a Flatpak's namespaced PID could collide + with another sandbox's window and mislabel it (Zen showed as "Bolt + Launcher").""" + name = (app_name or "").strip().lower() + if not name or name in _GENERIC_NAMES or name in _RUNTIME_BINARIES: + return True + return any(name.startswith(p.lower()) for p in _GENERIC_PREFIXES) + + +def _binary_name(binary, app_name): + """A friendly name from the process binary (e.g. "Cider" behind + "Chromium"), or None when the binary is a runtime ("java") or just echoes + app_name.""" + b = (binary or "").strip().rsplit("/", 1)[-1] # basename if it's a path + if not b or b.lower() in _RUNTIME_BINARIES or b.lower() == (app_name or "").strip().lower(): + return None + return b + + +def _to_int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + def list_audio_streams(): """Return [{id, app_name, media_name, node_name}, ...] for active output streams.""" import json as _json @@ -435,7 +488,25 @@ def list_audio_streams(): "media_name": props.get("media.name", ""), "node_name": node_name, "binary": props.get("application.process.binary", ""), + "_pid": _to_int(props.get("application.process.id")), }) + + # For generic names, prefer a meaningful binary ("Cider"), else the owning + # X11 window ("RuneLite"). X11 is looked up lazily, only when a generic + # stream has no usable binary, so the common path never touches Xlib. + pids = None + for stream in out: + pid = stream.pop("_pid") + if not _is_generic(stream["app_name"], stream["binary"]): + stream["display_name"] = stream["app_name"] + continue + name = _binary_name(stream["binary"], stream["app_name"]) + if not name: + if pids is None: + from . import wmnames + pids = wmnames.pid_names() + name = pids.get(pid) if pid else None + stream["display_name"] = name or stream["app_name"] return out # ----- application matching ------------------------------------------------- diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index 9a2cf2d..6631d71 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -319,7 +319,11 @@ def _populate_apps(self): self._listbox.append(empty) for app_name in sorted(apps.keys()): - row = Adw.ActionRow(title=app_name) + # display_name is a label only; app_name below stays the match key, + # so a row bound by its friendly title still captures by exact + # application.name equality. + row = Adw.ActionRow( + title=apps[app_name][0].get("display_name") or app_name) sample = apps[app_name][0].get("media_name") or apps[app_name][0].get("node_name", "") if sample: row.set_subtitle(sample) diff --git a/wavexlr/wmnames.py b/wavexlr/wmnames.py new file mode 100644 index 0000000..8e8abf2 --- /dev/null +++ b/wavexlr/wmnames.py @@ -0,0 +1,101 @@ +"""Best-effort friendly app names from the X11 window manager. + +Apps that play audio through the ALSA->PulseAudio bridge report a generic +PipeWire name ("ALSA plug-in [java]"), but their owning X11 window usually +carries the real one ("RuneLite"). We bridge the two the way KDE does: match the +audio stream's ``application.process.id`` to a window's ``_NET_WM_PID``, then read +that window's name. For sandboxed apps (Flatpak) the stream PID and the window +PID are the same namespaced value, so they still match even though the host +``/proc`` knows nothing about it. + +X11/XWayland only. Every failure path returns an empty map so callers fall back +to the PipeWire name; native-Wayland apps (no X11 window) just don't get enriched +and usually report a sane name already. + +Caveat: two different sandboxes can each have a low namespaced PID (both "2"), so +a generic-named stream could resolve to an unrelated sandbox's window. Callers +keep this lookup to genuinely-generic names (see pipewire._is_generic) to limit +the blast radius, but it can't be fully ruled out from PID alone. +""" + +import logging + +_log = logging.getLogger("wavexlr.wmnames") + + +def pid_names(): + """{pid (int): window name (str)} for current top-level X11 windows.""" + try: + from Xlib import X, display + from Xlib.error import XError + except Exception: + return {} + try: + d = display.Display() + except Exception: + return {} + try: + root = d.screen().root + a_clients = d.intern_atom("_NET_CLIENT_LIST") + a_pid = d.intern_atom("_NET_WM_PID") + a_name = d.intern_atom("_NET_WM_NAME") + a_utf8 = d.intern_atom("UTF8_STRING") + + clients = root.get_full_property(a_clients, X.AnyPropertyType) + if clients is None: + return {} + out = {} + for wid in clients.value: + try: + w = d.create_resource_object("window", wid) + pidp = w.get_full_property(a_pid, X.AnyPropertyType) + if not pidp or not pidp.value: + continue + pid = int(pidp.value[0]) + if pid in out: + continue + name = _window_name(w, a_name, a_utf8) + if name: + out[pid] = name + except (XError, Exception): # noqa: BLE001 — one bad window shouldn't sink the rest + continue + return out + except Exception: + return {} + finally: + try: + d.close() + except Exception: + pass + + +def _pick_name(res_class, wm_name): + """Choose the friendly name. A clean WM_CLASS is the stable app identity + ("Chromium") and beats _NET_WM_NAME, which for browsers/Electron is the + volatile tab/document title. But reverse-DNS or dashed classes + ("net-runelite-client-RuneLite", "com.adamcake.Bolt") are ugly, so for those + use the window title ("RuneLite", "Bolt Launcher").""" + res_class = (res_class or "").strip() + wm_name = (wm_name or "").strip() + if res_class and "." not in res_class and "-" not in res_class: + return res_class + return wm_name or res_class + + +def _window_name(w, a_name, a_utf8): + res_class = "" + try: + cls = w.get_wm_class() # (res_name, res_class) + if cls and cls[1]: + res_class = cls[1] + except Exception: + pass + wm_name = "" + try: + p = w.get_full_property(a_name, a_utf8) + if p and p.value: + v = p.value + wm_name = v.decode("utf-8", "replace") if isinstance(v, (bytes, bytearray)) else str(v) + except Exception: + pass + return _pick_name(res_class, wm_name) From 7ad87226b4c9cabe5115ec81bdfb89cd7ed918c4 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:39:05 -0500 Subject: [PATCH 55/99] Let the hardware track a slider drag instead of hearing about it after Gain, headphone volume and monitor mix each carried their own copy of the same 200 ms trailing-only debounce, so the device learned where a slider was only after the drag stopped: no live tracking, no LED ring following your hand, three copies of one idea drifting apart -- the matrix mic row had already grown a fourth. Ported Cryo's Throttler with its GLibScheduler seam: the first value fires immediately, then at most one per 80 ms while values keep arriving, then the final position once they stop. Keyed by control name so the sliders pace independently, clock injected so the pacing is tested with a hand-cranked scheduler -- no GLib, no main loop. All four call sites now share the one instance, and the trailing edge is under test because where the slider stopped is what must stick. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/test_throttler.py | 94 +++++++++++++++++++++++++++++++++++++++++ wavexlr/app.py | 31 ++++---------- wavexlr/scheduler.py | 89 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 tests/test_throttler.py create mode 100644 wavexlr/scheduler.py diff --git a/tests/test_throttler.py b/tests/test_throttler.py new file mode 100644 index 0000000..177e3be --- /dev/null +++ b/tests/test_throttler.py @@ -0,0 +1,94 @@ +"""Pacing the device sliders: leading, periodic, trailing — and nothing extra. + +Three sliders used to carry three copy-pasted 200 ms trailing-only debounces, +so the hardware heard about a drag only after it stopped. The Throttler sends +the first value immediately, then at most one per interval while the drag +continues, then the final position. Its clock is injected, so the pacing is +tested with a hand-cranked scheduler — no GLib, no main loop. +""" + +import unittest + +from wavexlr.scheduler import Throttler + + +class FakeScheduler: + """Timers fire only when tick() is called; time passes by hand.""" + + def __init__(self): + self._timers = {} + self._next = 0 + + def call_every(self, interval_s, fn): + handle = self._next + self._next += 1 + self._timers[handle] = fn + return handle + + def cancel(self, handle): + self._timers.pop(handle, None) + + def tick(self): + for handle, fn in list(self._timers.items()): + if not fn(): + self._timers.pop(handle, None) + + +class Pacing(unittest.TestCase): + def setUp(self): + self.sched = FakeScheduler() + self.throttle = Throttler(self.sched, 0.08) + self.sent = [] + + def push(self, name, value): + self.throttle.push(name, value, lambda v, n=name: self.sent.append((n, v))) + + def test_the_first_value_goes_out_immediately(self): + """A drag's first movement reaches the device with no delay at all — + the whole point over the trailing-only debounce it replaces.""" + self.push("gain", 10) + self.assertEqual(self.sent, [("gain", 10)]) + + def test_a_drag_is_paced_not_replayed(self): + """Many values inside one interval collapse to the latest.""" + self.push("gain", 10) + for v in (11, 12, 13, 14): + self.push("gain", v) + self.sched.tick() + self.assertEqual(self.sent, [("gain", 10), ("gain", 14)]) + + def test_the_final_position_is_never_dropped(self): + """The trailing edge: where the slider stopped is what must stick.""" + self.push("gain", 10) + self.push("gain", 55) + self.sched.tick() # sends 55 + self.sched.tick() # idle — timer stops + self.push("gain", 56) # a new drag leads again + self.assertEqual(self.sent[-1], ("gain", 56)) + + def test_an_idle_control_stops_ticking(self): + self.push("gain", 10) + self.sched.tick() + self.sched.tick() + self.assertEqual(self.sched._timers, {}) + + def test_sliders_pace_independently(self): + """Dragging gain must not delay or reorder headphone sends.""" + self.push("gain", 10) + self.push("hp", -20) + self.assertEqual(self.sent, [("gain", 10), ("hp", -20)]) + self.push("gain", 11) + self.push("hp", -21) + self.sched.tick() + self.assertIn(("gain", 11), self.sent) + self.assertIn(("hp", -21), self.sent) + + def test_cancel_all_stops_every_timer(self): + self.push("gain", 10) + self.push("hp", -20) + self.throttle.cancel_all() + self.assertEqual(self.sched._timers, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index da881fc..3633e11 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -67,9 +67,11 @@ def __init__(self, **kwargs): self._poll_id = None self._stream_poll_id = None self._device_poll_countdown = self._DEVICE_POLL_EVERY - self._gain_timeout = None - self._hp_timeout = None - self._mix_timeout = None + # One pacer for every device slider. 80 ms leading+periodic+trailing, + # so the hardware tracks during a drag instead of hearing about it + # 200 ms after the drag stops. + from .scheduler import GLibScheduler, Throttler + self._throttle = Throttler(GLibScheduler(), 0.08) # Debounce slider events to coalesce a flurry of value-changed signals # during a drag into one set_cell. {(source_id, mix_id): timeout_id}. self._cell_debounce_ids = {} @@ -690,29 +692,20 @@ def _on_gain_changed(self, scale): return val = int(scale.get_value()) self.gain_label.set_label(self._format_gain(val)) - # Debounce — only send after slider stops moving for 200ms - if hasattr(self, '_gain_timeout') and self._gain_timeout: - GLib.source_remove(self._gain_timeout) - self._gain_timeout = GLib.timeout_add(200, self._send_gain, val) + self._throttle.push("gain", val, self._send_gain) def _send_gain(self, val): - self._gain_timeout = None self._usb_async(lambda: self.dev.set_gain_raw(val), on_error=self._on_usb_error) - return False def _on_hp_changed(self, scale): if self._updating_ui or not self.dev.connected: return db = scale.get_value() self.hp_label.set_label(f"{db:.1f} dB") - if hasattr(self, '_hp_timeout') and self._hp_timeout: - GLib.source_remove(self._hp_timeout) - self._hp_timeout = GLib.timeout_add(200, self._send_hp, db) + self._throttle.push("hp", db, self._send_hp) def _send_hp(self, db): - self._hp_timeout = None self._usb_async(lambda: self.dev.set_hp_volume_db(db), on_error=self._on_usb_error) - return False # ----- per-mix output routing (shown in each column header's menu) ----- def _output_entries(self, mix_id, sinks, default_sink): @@ -908,14 +901,10 @@ def _on_mix_changed(self, scale): return val = int(scale.get_value()) self.mix_label.set_label(f"{val / 256:.0f}%") - if self._mix_timeout: - GLib.source_remove(self._mix_timeout) - self._mix_timeout = GLib.timeout_add(200, self._send_mix, val) + self._throttle.push("mix", val, self._send_mix) def _send_mix(self, val): - self._mix_timeout = None self._usb_async(lambda: self.dev.set_monitor_mix(val), on_error=self._on_usb_error) - return False def _on_mic_matrix_volume_changed(self, _source, value): if self._updating_ui or not self.dev.connected: @@ -925,9 +914,7 @@ def _on_mic_matrix_volume_changed(self, _source, value): self._updating_ui = True self.gain_scale.set_value(raw) self._updating_ui = False - if self._gain_timeout: - GLib.source_remove(self._gain_timeout) - self._gain_timeout = GLib.timeout_add(200, self._send_gain, raw) + self._throttle.push("gain", raw, self._send_gain) def _on_mic_matrix_mute_toggled(self, _source, muted): if self._updating_ui or not self.dev.connected: diff --git a/wavexlr/scheduler.py b/wavexlr/scheduler.py new file mode 100644 index 0000000..f3c1190 --- /dev/null +++ b/wavexlr/scheduler.py @@ -0,0 +1,89 @@ +"""Scheduler — the timing/threading seam the DeviceController runs on. + +The controller never touches GLib or threads directly: it asks a Scheduler to +run blocking USB work off the main thread and to fire repeating timers, and to +marshal results back. GLibScheduler is the production adapter; a fake +(synchronous, controllable-clock) one lets the connect/poll/reconnect logic be +exercised without a GTK main loop or a real device. + +Interface (duck-typed): + run_async(fn, on_done=None, on_error=None) + Run fn() off the main thread; deliver on_done(result) or on_error(exc) + back on the main thread. + call_every(interval_s, fn) -> handle + Call fn() every interval_s seconds; fn returns True to keep going. + cancel(handle) + Stop a timer started by call_every. +""" + +import threading + +from gi.repository import GLib + + +class GLibScheduler: + """Production scheduler: GLib timeouts + worker threads marshalled via idle_add.""" + + def run_async(self, fn, on_done=None, on_error=None): + def _worker(): + try: + result = fn() + if on_done is not None: + GLib.idle_add(on_done, result) + except Exception as e: + if on_error is not None: + GLib.idle_add(on_error, e) + threading.Thread(target=_worker, daemon=True).start() + + def call_every(self, interval_s, fn): + return GLib.timeout_add(int(interval_s * 1000), fn) + + def cancel(self, handle): + if handle is not None: + GLib.source_remove(handle) + + +class Throttler: + """Paces rapid live updates (mixer + device sliders) so a drag doesn't flood + the device or the audio graph: the first value fires immediately (leading), + then at most once per interval while values keep arriving (periodic), then a + final trailing value once they stop. Keyed by name so independent sliders + pace independently. + + The Throttler owns only the timing; the caller's `setter` owns dispatch — + inline for the mixer (which queues to its own worker), or off-thread with a + connected-guard for the device. Runs on an injected Scheduler, so the pacing + is testable with a controllable-clock fake (no GLib, no main loop).""" + + def __init__(self, scheduler, interval_s): + self._sched = scheduler + self._interval = interval_s + self._pending = {} # name -> latest value awaiting send + self._setter = {} # name -> callable(value) + self._handle = {} # name -> timer handle (None = idle) + + def push(self, name, value, setter): + """Record the latest value for `name` and send it, paced.""" + self._pending[name] = value + self._setter[name] = setter + if self._handle.get(name) is None: + self._flush(name) # leading edge + self._handle[name] = self._sched.call_every( + self._interval, lambda n=name: self._tick(n)) + + def _tick(self, name): + if name in self._pending: # value changed since last flush + self._flush(name) + return True # keep the timer alive + self._handle[name] = None # idle — stop ticking + return False + + def _flush(self, name): + if name not in self._pending: + return + self._setter[name](self._pending.pop(name)) + + def cancel_all(self): + for handle in self._handle.values(): + self._sched.cancel(handle) + self._handle.clear() From 7ca99807992f36e2549f3dd71c9651331ee69457 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:39:47 -0500 Subject: [PATCH 56/99] Stop the picker offering an app some row already matches _bound_capture_nodes() keeps a device from being added twice, but there was no equivalent for application names: the Add Source picker listed every running app, and confirming added unconditionally, so a second row could be bound to an app another row already claims. The audio side never had the problem -- claim_streams() gives every stream exactly one owner, most-specific match first -- so the duplicate could not double-route or double-amplify. What it could do is sit in the matrix as a silently inert fader, which reads as the application being broken rather than the row being redundant. The guard mirrors the node one: a bound-app-names set built from bindings(), so a multi-name row excludes all of its names, passed to the dialog and filtered case-insensitively, the way stream matching already compares. The empty state says "No new apps playing audio" when everything playing is already bound. Deliberately not ported from the same Cryo commit: its hard reject on confirm, since a catch-all row and a specific row legitimately overlap in this model. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- wavexlr/app.py | 18 +++++++++++++++++- wavexlr/sourcedialog.py | 11 +++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 3633e11..5324808 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1105,11 +1105,27 @@ def _set_source_level(self, source_id, level): cell.set_level(level) def _on_add_source_clicked(self, _matrix): - dialog = AddSourceDialog(exclude_nodes=self._bound_capture_nodes()) + dialog = AddSourceDialog( + exclude_nodes=self._bound_capture_nodes(), + exclude_apps=self._bound_app_names(), + ) dialog.connect("source-confirmed", self._on_source_confirmed) dialog.connect("device-source-confirmed", self._on_device_source_confirmed) dialog.present(self) + def _bound_app_names(self): + """Application names some row already matches, so the picker cannot + offer a duplicate. claim_streams() gives every stream exactly one + owner regardless, so a duplicate could never double-route -- but it + would sit in the matrix as a silently inert fader, which reads as + broken. Built from bindings() so multi-name rows cover all of theirs. + """ + return { + name + for source in self._sources.values() + for name in sources_module.bindings(source) + } + def _bound_capture_nodes(self): """Capture nodes that already have a row, so the picker cannot make a duplicate. The Wave's own mic is in the set: it is the built-in row, diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index 6631d71..e12ea5c 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -50,7 +50,7 @@ class AddSourceDialog(Adw.Dialog): "source-edited": (GObject.SignalFlags.RUN_FIRST, None, (str, str, str, str, str)), } - def __init__(self, source=None, *, exclude_nodes=()): + def __init__(self, source=None, *, exclude_nodes=(), exclude_apps=()): super().__init__() self._source = source self._editing_device = ( @@ -66,6 +66,9 @@ def __init__(self, source=None, *, exclude_nodes=()): # Capture nodes that already have a matrix row. self._exclude_nodes = frozenset(exclude_nodes) + # Compared case-insensitively, the same way stream matching does: + # a row bound to "spotify" already covers the app reporting "Spotify". + self._exclude_apps = frozenset(a.casefold() for a in exclude_apps) # None = nothing picked yet, "" = manual entry, else the picked app. # Every binding, comma-separated: a source can gather more than one # application, and an edit that showed only the first would silently @@ -310,10 +313,14 @@ def _populate_apps(self): streams = list_audio_streams() apps = {} for s in streams: + if s["app_name"].casefold() in self._exclude_apps: + continue apps.setdefault(s["app_name"], []).append(s) if not apps: - empty = Adw.ActionRow(title="No audio streams playing") + title = ("No new apps playing audio" if self._exclude_apps + else "No audio streams playing") + empty = Adw.ActionRow(title=title) empty.set_subtitle("Start playback in an app, or enter a name manually below") empty.set_sensitive(False) self._listbox.append(empty) From 5c90ca1823e8c6f84e616cb73b67d4c1ad1756a2 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:41:13 -0500 Subject: [PATCH 57/99] Discover the ALSA controls by name instead of trusting numids 4/5/6 Mute, gain and headphone volume were addressed by hardcoded numids, which are not promised to hold across firmware revisions or models. The control names vary too -- but only in their product-string prefix ("PCM Playback Volume", "Mic Capture Switch" on the XLR Dock) -- so the suffix is the stable handle. Ported from CryoByte33/openwave. One amixer contents pass per card maps suffix to numid and feeds the existing max cache on the way, so the clamp costs no extra call. A role the scan cannot find falls back to the historical numid, so a card this has never seen behaves exactly as before -- discovery can add coverage but cannot regress it. Verified against a live 0fd9:00a6 XLR Dock, where discovery resolves to exactly the numbers that were hardcoded; that capture is the test fixture, alongside a renumbered imaginary firmware that the fallback alone could not have handled. The original Wave XLR was not on hand, which is precisely what the fallback is for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/test_numid_discovery.py | 110 ++++++++++++++++++++++++++++++++++ wavexlr/device.py | 75 +++++++++++++++++++---- 2 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 tests/test_numid_discovery.py diff --git a/tests/test_numid_discovery.py b/tests/test_numid_discovery.py new file mode 100644 index 0000000..53fb5b5 --- /dev/null +++ b/tests/test_numid_discovery.py @@ -0,0 +1,110 @@ +"""Finding the ALSA controls by name instead of trusting their numbers. + +numid=4/5/6 hold on the hardware in hand, but numids are not promised across +firmware revisions or models. The control names vary only in their +product-string prefix -- the XLR Dock says "PCM Playback Volume" and +"Mic Capture Switch" -- so the suffix is what gets matched. Discovery is fed +the amixer output verbatim; a card it cannot read falls back to the +historical numbers, so nothing that works today can regress. +""" + +import unittest +from unittest import mock + +from wavexlr import device + +# Captured from a real 0fd9:00a6 XLR Dock, 2026-08-30. +DOCK = """\ +numid=3,iface=MIXER,name='PCM Playback Switch' + ; type=BOOLEAN,access=rw------,values=1 + : values=on +numid=4,iface=MIXER,name='PCM Playback Volume' + ; type=INTEGER,access=rw---R--,values=1,min=0,max=120,step=0 + : values=73 +numid=5,iface=MIXER,name='Mic Capture Switch' + ; type=BOOLEAN,access=rw------,values=1 + : values=on +numid=6,iface=MIXER,name='Mic Capture Volume' + ; type=INTEGER,access=rw---R--,values=1,min=0,max=150,step=0 + : values=150 +numid=2,iface=PCM,name='Capture Channel Map' + ; type=INTEGER,access=r--v-R--,values=1,min=0,max=36,step=0 + : values=2 +""" + +# The same controls under different numids and another product prefix. +SHUFFLED = """\ +numid=11,iface=MIXER,name='Wave XLR Mk3 Capture Switch' + ; type=BOOLEAN,access=rw------,values=1 + : values=on +numid=12,iface=MIXER,name='Wave XLR Mk3 Capture Volume' + ; type=INTEGER,access=rw---R--,values=1,min=0,max=200,step=0 + : values=0 +numid=13,iface=MIXER,name='Wave XLR Mk3 Playback Volume' + ; type=INTEGER,access=rw---R--,values=1,min=0,max=99,step=0 + : values=0 +""" + + +class Discovery(unittest.TestCase): + def setUp(self): + device._ALSA_NUMIDS.clear() + device._ALSA_CTL_MAX.clear() + self.addCleanup(device._ALSA_NUMIDS.clear) + self.addCleanup(device._ALSA_CTL_MAX.clear) + + def with_amixer(self, output): + ctx = mock.patch.object( + device, "_amixer", + lambda card, *args: output if args == ("contents",) else "") + ctx.start() + self.addCleanup(ctx.stop) + + def test_the_dock_resolves_to_its_historical_numids(self): + """The capture above is real hardware; discovery must agree with the + numbers that were hardcoded, or discovery is what regresses.""" + self.with_amixer(DOCK) + self.assertEqual(device._numid("c", "mute"), 5) + self.assertEqual(device._numid("c", "gain"), 6) + self.assertEqual(device._numid("c", "hp_vol"), 4) + + def test_moved_controls_are_still_found(self): + """The case the fallback cannot cover: a firmware that renumbers.""" + self.with_amixer(SHUFFLED) + self.assertEqual(device._numid("c", "mute"), 11) + self.assertEqual(device._numid("c", "gain"), 12) + self.assertEqual(device._numid("c", "hp_vol"), 13) + + def test_discovery_also_learns_the_maxima(self): + """One pass feeds the max cache, so the clamp needs no second call.""" + self.with_amixer(SHUFFLED) + device._discover_numids("c") + self.assertEqual(device._ALSA_CTL_MAX[("c", 12)], 200) + self.assertEqual(device._ALSA_CTL_MAX[("c", 13)], 99) + + def test_an_unreadable_card_falls_back(self): + self.with_amixer("") + self.assertEqual(device._numid("c", "mute"), 5) + self.assertEqual(device._numid("c", "gain"), 6) + self.assertEqual(device._numid("c", "hp_vol"), 4) + + def test_a_non_mixer_interface_is_not_a_control(self): + """'Capture Channel Map' is iface=PCM; matching it would be wrong + even though nothing in the suffix table collides with it today.""" + self.with_amixer(DOCK) + found = device._discover_numids("c") + self.assertNotIn(2, found.values()) + + def test_the_scan_runs_once_per_card(self): + calls = [] + with mock.patch.object( + device, "_amixer", + lambda card, *a: calls.append(card) or DOCK): + device._numid("c", "mute") + device._numid("c", "gain") + device._numid("c", "hp_vol") + self.assertEqual(calls, ["c"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/device.py b/wavexlr/device.py index a43fee3..320efaf 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -114,11 +114,10 @@ def _amixer(card, *args): def _alsa_get(card): """Read ALSA mute and HP volume.""" state = {} - # Mute (numid=5) - out = _amixer(card, "cget", "numid=5") + out = _amixer(card, "cget", f"numid={_numid(card, 'mute')}") state["mute"] = ": values=off" in out - # HP volume (numid=4) — raw ALSA value 0-120 - out = _amixer(card, "cget", "numid=4") + # HP volume — raw ALSA value 0-120 + out = _amixer(card, "cget", f"numid={_numid(card, 'hp_vol')}") for line in out.splitlines(): if ": values=" in line: try: @@ -128,6 +127,57 @@ def _alsa_get(card): return state +# ALSA control name suffix -> role. The numids 4/5/6 hold on the hardware in +# hand but are not promised across firmware revisions or models; the control +# NAMES vary only in their product-string prefix ("PCM Playback Volume", +# "Mic Capture Switch" on the XLR Dock), so the suffix is the stable handle. +# Ported from CryoByte33/openwave and verified against a live 0fd9:00a6 Dock, +# where discovery resolves to exactly the numbers below. +_ALSA_ROLE_SUFFIX = { + "Capture Switch": "mute", + "Capture Volume": "gain", + "Playback Volume": "hp_vol", +} +_ALSA_ROLE_FALLBACK = {"mute": 5, "gain": 6, "hp_vol": 4} +_ALSA_NUMIDS = {} # card -> {role: numid}, cached like the maxima below + + +def _discover_numids(card): + """{role: numid} scanned from `amixer contents`, by control-name suffix. + + One pass also feeds the max cache, so discovery costs no extra calls. + Anything not found falls back to the historical hardcoded numid, so a + device this has never seen behaves exactly as before. + """ + if card in _ALSA_NUMIDS: + return _ALSA_NUMIDS[card] + found = {} + cur_id = cur_name = None + for line in _amixer(card, "contents").splitlines(): + stripped = line.strip() + m = re.match(r"numid=(\d+),iface=(\w+),name='(.*)'", stripped) + if m: + cur_id, iface, cur_name = int(m.group(1)), m.group(2), m.group(3) + if iface != "MIXER": + cur_id = cur_name = None + continue + if cur_id is not None and stripped.startswith("; type="): + role = next((r for suffix, r in _ALSA_ROLE_SUFFIX.items() + if cur_name.endswith(suffix)), None) + if role and role not in found: + found[role] = cur_id + m = re.search(r",max=(-?\d+)", stripped) + if m: + _ALSA_CTL_MAX[(card, cur_id)] = int(m.group(1)) + _ALSA_NUMIDS[card] = found + return found + + +def _numid(card, role): + """The numid carrying a role on this card, discovered or historical.""" + return _discover_numids(card).get(role, _ALSA_ROLE_FALLBACK[role]) + + # Control ranges differ per device and per kernel driver, so they are read # from the driver rather than assumed. Cached: they cannot change for a card. _ALSA_CTL_MAX = {} @@ -143,19 +193,22 @@ def _alsa_ctl_max(card, numid, fallback): def _alsa_set_mute(card, muted): - _amixer(card, "cset", "numid=5", "off" if muted else "on") + _amixer(card, "cset", f"numid={_numid(card, 'mute')}", + "off" if muted else "on") def _alsa_set_hp_vol(card, value): - """Set ALSA HP volume (numid=4), clamped to the control's real range.""" - top = _alsa_ctl_max(card, 4, 120) - _amixer(card, "cset", "numid=4", str(max(0, min(top, value)))) + """Set ALSA HP volume, clamped to the control's real range.""" + numid = _numid(card, "hp_vol") + top = _alsa_ctl_max(card, numid, 120) + _amixer(card, "cset", f"numid={numid}", str(max(0, min(top, value)))) def _alsa_set_gain(card, value): - """Set ALSA mic gain (numid=6), clamped to the control's real range.""" - top = _alsa_ctl_max(card, 6, 150) - _amixer(card, "cset", "numid=6", str(max(0, min(top, value)))) + """Set ALSA mic gain, clamped to the control's real range.""" + numid = _numid(card, "gain") + top = _alsa_ctl_max(card, numid, 150) + _amixer(card, "cset", f"numid={numid}", str(max(0, min(top, value)))) def _fw_gain_to_alsa(fw_gain_raw, scale): From 0438a037f03f8d8d9b4f09988e571c24019de59d Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:42:43 -0500 Subject: [PATCH 58/99] Reconnect to a Wave that appears after launch Connecting happened exactly twice: at startup and when the Refresh button was clicked. Plug the device in after launch, or replug it after a USB drop, and the sidebar stayed dead until someone thought to click. A 2 s presence tick now runs while disconnected, started from every path that loses the device -- connect failure, poll error, USB write error -- and stopped the moment a connect succeeds, so it never runs alongside a healthy poll. Presence is read from sysfs idVendor/idProduct against the profile table: no USB enumeration, no permissions needed, cheap enough that the tick costs nothing. The moment a supported device is on the bus it hands off to the normal connect path, shaped after CryoByte33/openwave's reconnect loop. The spec's companion question -- whether Mixer.mic/hp go stale after a physical replug -- resolves by reading: find_wave_xlr_alsa() returns PipeWire node names, which embed the USB serial and therefore survive a replug of the same unit unchanged. What remains is narrower and noted here rather than fixed: a Mixer constructed with no Wave present keeps mic=hp=None until restart, since nothing re-runs the lookup. That re-detect belongs with the planned PipeWire adapter seam, where it can be tested; bolting it on here would touch the mixer blind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- wavexlr/app.py | 28 +++++++++++++++++++++++++++- wavexlr/device.py | 23 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 5324808..0efeb74 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -23,7 +23,7 @@ from .sourcedialog import AddSourceDialog from . import (paths, setup, service, sources as sources_module, mixes as mixes_module, desktop as desktop_module, - recovery as recovery_module) + recovery as recovery_module, device as device_module) logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") @@ -65,6 +65,7 @@ def __init__(self, **kwargs): self._updating_ui = False self._last_state = None self._poll_id = None + self._reconnect_id = None self._stream_poll_id = None self._device_poll_countdown = self._DEVICE_POLL_EVERY # One pacer for every device slider. 80 ms leading+periodic+trailing, @@ -578,8 +579,31 @@ def _done(result): def _fail(e): self.status_label.set_label("Disconnected") self.status_label.add_css_class("dim-label") + self._start_reconnect() self._usb_async(_connect, _done, _fail) + def _start_reconnect(self): + """Watch for a Wave appearing, so plugging one in needs no Refresh. + + A 2 s sysfs presence check while disconnected; the moment a supported + device is on the bus, hand off to the normal connect path. The tick + stops itself once connected and restarts from the failure paths, so + it never runs alongside a healthy poll. + """ + if self._reconnect_id: + return + self._reconnect_id = GLib.timeout_add_seconds(2, self._reconnect_tick) + + def _reconnect_tick(self): + if self.dev.connected: + self._reconnect_id = None + return False + if device_module.wave_present(): + self._reconnect_id = None + self._try_connect() + return False + return True + def _start_polling(self): """Start 10 Hz polling to sync hardware state.""" if self._poll_id: @@ -610,6 +634,7 @@ def _on_poll_error(self, e): self.dev.disconnect() self._stop_polling() self._notify_tray() + self._start_reconnect() def _apply_profile(self, profile): """Adapt the UI to the connected device model.""" @@ -680,6 +705,7 @@ def _on_usb_error(self, e): self.dev.disconnect() self._stop_polling() self._notify_tray() + self._start_reconnect() def _on_mute_changed(self, row, _pspec): if self._updating_ui or not self.dev.connected: diff --git a/wavexlr/device.py b/wavexlr/device.py index 320efaf..0f2b2b5 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -127,6 +127,29 @@ def _alsa_get(card): return state +def wave_present(): + """True when any supported Wave is on the USB bus. Sysfs only -- no USB + permissions, no enumeration, cheap enough for a 2 s reconnect tick.""" + from .profiles import PROFILES + wanted = {(f"{p.vid:04x}", f"{p.pid:04x}") for p in PROFILES} + base = "/sys/bus/usb/devices" + try: + entries = os.listdir(base) + except OSError: + return False + for entry in entries: + try: + with open(os.path.join(base, entry, "idVendor")) as f: + vid = f.read().strip() + with open(os.path.join(base, entry, "idProduct")) as f: + pid = f.read().strip() + except OSError: + continue + if (vid, pid) in wanted: + return True + return False + + # ALSA control name suffix -> role. The numids 4/5/6 hold on the hardware in # hand but are not promised across firmware revisions or models; the control # NAMES vary only in their product-string prefix ("PCM Playback Volume", From b9a338b1d074abbc00355045db21f198d0029eb8 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:44:34 -0500 Subject: [PATCH 59/99] Credit CryoByte33/openwave where its work was taken The five ported commits name the source in their messages, but history is not where credit is read: the two modules taken essentially verbatim now say so in their own docstrings, and the README's Credits section names the fork and lists what came from it -- the friendly-name resolution, the Throttler and its scheduler seam, numid discovery by control-name suffix, the hotplug reconnect, the picker guard, and the MK.2 0x00B6 protocol work this tree defers only for lack of the hardware. Co-Authored-By: cryobyte33 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- README.md | 8 ++++++++ wavexlr/scheduler.py | 6 +++++- wavexlr/wmnames.py | 4 ++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e221c3..f5d8353 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,14 @@ first, since the firmware serves one process at a time. USB protocol reverse-engineered from the macOS Wave Link application using Frida. Inspired by [GoXLR-on-Linux/goxlr-utility](https://github.com/GoXLR-on-Linux/goxlr-utility). +Several ideas and two modules are ported from +[CryoByte33/openwave](https://github.com/CryoByte33/openwave), a sibling fork: +the friendly-app-name resolution (`wmnames.py` and the generic-name rules), +the slider `Throttler` and its scheduler seam, ALSA control discovery by +name suffix, the hotplug reconnect loop, and the duplicate-source picker +guard. cryobyte33's fork also decoded the Wave XLR MK.2 (`0fd9:00b6`) vendor +protocol, which this tree defers only for lack of that hardware. + ## License MIT diff --git a/wavexlr/scheduler.py b/wavexlr/scheduler.py index f3c1190..8bb8961 100644 --- a/wavexlr/scheduler.py +++ b/wavexlr/scheduler.py @@ -1,4 +1,8 @@ -"""Scheduler — the timing/threading seam the DeviceController runs on. +"""Scheduler — the timing/threading seam device work runs on. + +Ported from CryoByte33/openwave (github.com/CryoByte33/openwave): both the +Scheduler seam and the Throttler's leading/periodic/trailing pacing are +cryobyte33's design, taken essentially verbatim. The controller never touches GLib or threads directly: it asks a Scheduler to run blocking USB work off the main thread and to fire repeating timers, and to diff --git a/wavexlr/wmnames.py b/wavexlr/wmnames.py index 8e8abf2..96094f4 100644 --- a/wavexlr/wmnames.py +++ b/wavexlr/wmnames.py @@ -1,5 +1,9 @@ """Best-effort friendly app names from the X11 window manager. +Ported from CryoByte33/openwave (github.com/CryoByte33/openwave), essentially +verbatim -- the design, the PID-to-window bridge and the WM_CLASS-vs-title +rule are cryobyte33's work. + Apps that play audio through the ALSA->PulseAudio bridge report a generic PipeWire name ("ALSA plug-in [java]"), but their owning X11 window usually carries the real one ("RuneLite"). We bridge the two the way KDE does: match the From 74d6aefb622208cd2a0d6be726fe260017e0786b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:47:11 -0500 Subject: [PATCH 60/99] Let the app be called OpenWave, and say what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header title was a status label, so on connect the application renamed itself to "OpenWave — Wave XLR MK.2": the hardware's name where the program's belongs. Adw.WindowTitle separates the two -- the title is OpenWave, always, and the subtitle carries what the label was juggling: Connecting…, Disconnected, or the connected device's model, which still matters (two Elgato interfaces at once is a supported setup) but is identification, not identity. The tagline grows up with it. "Elgato Wave Control for Linux" described the app it used to be; it has been a general mixing matrix -- user- defined mixes, per-app rows, per-mix outputs -- for some time, with Wave control as the hardware half. Desktop entries, package descriptions and the README lead now say "The audio mixing matrix for Linux", with the package descriptions carrying the one-line elaboration. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- PKGBUILD | 2 +- README.md | 2 +- flake.nix | 4 ++-- openwave-autostart.desktop | 2 +- wavexlr.desktop | 2 +- wavexlr/app.py | 24 ++++++++++++------------ 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/PKGBUILD b/PKGBUILD index 67f36ea..e8922c6 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -2,7 +2,7 @@ pkgname=openwave pkgver=1.0.0 pkgrel=1 -pkgdesc="Linux control application for the Elgato Wave XLR and Wave:3" +pkgdesc="The audio mixing matrix for Linux — per-app mixes, per-mix outputs, Elgato Wave control" arch=('any') url="https://github.com/rikkichy/openwave" license=('MIT') diff --git a/README.md b/README.md index f5d8353..2ba2584 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenWave -Linux control application for **Elgato Wave** audio devices — the **Wave XLR** microphone interface and the **Wave:3** microphone. A reverse-engineered replacement for Elgato Wave Link, built with GTK4 + Adwaita. +**The audio mixing matrix for Linux.** Per-app mixes with per-mix outputs, plus native control of **Elgato Wave** hardware — the **Wave XLR** interface (original and MK.2/XLR Dock) and the **Wave:3** microphone. A reverse-engineered replacement for Elgato Wave Link, built with GTK4 + Adwaita. ![OpenWave](docs/screenshot.png) diff --git a/flake.nix b/flake.nix index 46da6ef..367005c 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "OpenWave - Linux control app for the Elgato Wave XLR"; + description = "OpenWave - The audio mixing matrix for Linux"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -87,7 +87,7 @@ ''; meta = { - description = "Linux control application for the Elgato Wave XLR interface"; + description = "The audio mixing matrix for Linux — per-app mixes, per-mix outputs, Elgato Wave control"; homepage = "https://github.com/rikkichy/openwave"; license = pkgs.lib.licenses.mit; mainProgram = "openwave"; diff --git a/openwave-autostart.desktop b/openwave-autostart.desktop index 3ea9932..571af58 100644 --- a/openwave-autostart.desktop +++ b/openwave-autostart.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Name=OpenWave -Comment=Elgato Wave Control for Linux +Comment=The audio mixing matrix for Linux Exec=openwave --hide Icon=audio-input-microphone Type=Application diff --git a/wavexlr.desktop b/wavexlr.desktop index f08bdc8..ee39a89 100644 --- a/wavexlr.desktop +++ b/wavexlr.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Name=OpenWave -Comment=Elgato Wave Control for Linux +Comment=The audio mixing matrix for Linux Exec=openwave Icon=openwave Type=Application diff --git a/wavexlr/app.py b/wavexlr/app.py index 0efeb74..03cb8a3 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -191,9 +191,13 @@ def _build_ui(self): # Header bar header = Adw.HeaderBar() - self.status_label = Gtk.Label(label="Disconnected") - self.status_label.add_css_class("dim-label") - header.set_title_widget(self.status_label) + # The application's name is the title; the device and the connection + # state are the subtitle. The device model used to BE the title + # ("OpenWave — Wave XLR MK.2"), which read as the app being called + # that -- and the header is not where hardware identification lives. + self._window_title = Adw.WindowTitle( + title="OpenWave", subtitle="Disconnected") + header.set_title_widget(self._window_title) # Audio-service status. Packed at the start and hidden while healthy, # so it costs nothing until it has something to say -- it used to be a @@ -557,7 +561,7 @@ def _worker(): threading.Thread(target=_worker, daemon=True).start() def _try_connect(self): - self.status_label.set_label("Connecting...") + self._window_title.set_subtitle("Connecting…") def _connect(): self.dev.disconnect() self.dev.connect() @@ -569,7 +573,6 @@ def _connect(): return {"state": self.dev.get_all(), "info": info} def _done(result): self._apply_profile(self.dev.profile) - self.status_label.remove_css_class("dim-label") self._apply_state(result["state"]) info = result["info"] self.fw_label.set_label(info.get("fw_version", "—")) @@ -577,8 +580,7 @@ def _done(result): self.serial_label.set_label(info.get("serial", "—")) self._start_polling() def _fail(e): - self.status_label.set_label("Disconnected") - self.status_label.add_css_class("dim-label") + self._window_title.set_subtitle("Disconnected") self._start_reconnect() self._usb_async(_connect, _done, _fail) @@ -629,8 +631,7 @@ def _on_poll_result(self, state): self._apply_state(state) def _on_poll_error(self, e): - self.status_label.set_label("Disconnected") - self.status_label.add_css_class("dim-label") + self._window_title.set_subtitle("Disconnected") self.dev.disconnect() self._stop_polling() self._notify_tray() @@ -651,7 +652,7 @@ def _apply_profile(self, profile): # Elgato devices connected the profile that opened over USB and the # capture node this row carries can be different hardware, and a row # labelled after the wrong one is worse than a generic label. - self.status_label.set_label(f"OpenWave — {profile.display_name}") + self._window_title.set_subtitle(profile.display_name) def _format_gain(self, raw): scale = self.dev.profile.gain_scale if self.dev.profile else None @@ -700,8 +701,7 @@ def _notify_tray(self): app.refresh_tray() def _on_usb_error(self, e): - self.status_label.set_label("Disconnected") - self.status_label.add_css_class("dim-label") + self._window_title.set_subtitle("Disconnected") self.dev.disconnect() self._stop_polling() self._notify_tray() From ba3596c9a384f511f69f7f07edf24f22718c38e1 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:52:57 -0500 Subject: [PATCH 61/99] Put a seam between the mixer's decisions and the machine's audio Everything the mixer does to the graph ran through ~30 scattered subprocess calls, so nothing could test the reconcile and spawn paths -- the layer where the worst regressions have lived: double-routed audio, loopbacks against dead links, faders driving nothing. bare_mixer() got construction without hardware and one test monkeypatched subprocess.run, but call-sequence assertions against the routing logic were not writable. SubprocessPipeWire is that seam, shaped after CryoByte33/openwave's adapter of the same name but with methods matching this mixer's own call shapes. Deliberately thin: every method delegates to the module-level implementation it names, at call time, so the battle-tested subprocess code moves nowhere and existing tests that patch those functions keep intercepting the real adapter. Mixer takes pw= and calls only the seam; four inline subprocess sites (pw-link, set-default-sink, the loopback Popen, the stale-loopback pkill) are extracted into named functions so the adapter has something to name. The payoff lands with it: FakePipeWire in the test support -- a graph made of dicts that records every call -- and the first tests the reconcile layer has ever had. Among them, the property the cryo-port spec called immune-by-construction is now enforced rather than believed: cell volume is reapplied on every pass, which is what made Cryo's 07579a1 unnecessary here. The seam also unblocks the item parked on it: mic/hp were resolved exactly once, in __init__, so a Mixer built with no Wave present kept both None until restart -- the USB side reconnected, monitoring stayed pointed at nothing. redetect_device() re-asks the graph from the connect path and reconciles only when the answer changed, which the hotplug reconnect from 0438a03 now completes end to end. Co-Authored-By: cryobyte33 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/support.py | 106 +++++++++++++++ tests/test_mixer_reconcile.py | 197 +++++++++++++++++++++++++++ wavexlr/app.py | 3 + wavexlr/mixer.py | 244 ++++++++++++++++++++++++---------- 4 files changed, 481 insertions(+), 69 deletions(-) create mode 100644 tests/test_mixer_reconcile.py diff --git a/tests/support.py b/tests/support.py index 824ebd0..2908ad6 100644 --- a/tests/support.py +++ b/tests/support.py @@ -56,6 +56,10 @@ def bare_mixer(**attrs): mx.hp = None mx._started = False mx._volumes_restored = True + # The default seam delegates to the module-level functions at call time, + # so a test that patches mixer_mod._pactl_set_sink_volume still + # intercepts. Pass _pw=FakePipeWire() instead to assert on graph calls. + mx._pw = mixer_mod.SubprocessPipeWire() # set_cell and friends enqueue their reconcile even with no worker # running. The queue is the seam: work lands in _pending and stays there, # so a test can call the real entry points and inspect the state they @@ -66,3 +70,105 @@ def bare_mixer(**attrs): for key, value in attrs.items(): setattr(mx, key, value) return mx + + +class FakeProc: + """A loopback process that never was: records its lifecycle.""" + + def __init__(self, argv): + self.argv = argv + self.terminated = False + self.killed = False + self._returncode = None + + def poll(self): + return self._returncode + + def wait(self, timeout=None): + return self._returncode if self._returncode is not None else 0 + + def terminate(self): + self.terminated = True + self._returncode = 0 + + def kill(self): + self.killed = True + self._returncode = -9 + + def dies(self): + """Simulate an out-of-band death, PipeWire restarting under it.""" + self._returncode = 1 + + +class FakePipeWire: + """A PipeWire graph made of dicts, recording every call in order. + + Configure what exists (node ids, ports, streams, sink volumes); read + back `calls` to assert what the mixer decided to do about it. Nothing + here spawns a process or needs a sound card. + """ + + def __init__(self): + self.calls = [] + self.node_ids = {} # node_name -> id + self.port_map = {} # (flag, node_name) -> [ports] + self.streams = [] + self.volumes = {} # sink_name -> (volume, muted) + self.default = "default_sink" + self.spawned = [] # FakeProc, in spawn order + self.spawn_fails = False + + def short_list(self, kind): + self.calls.append(("short_list", kind)) + return [] + + def sink_volumes(self): + self.calls.append(("sink_volumes",)) + return dict(self.volumes) + + def set_sink_volume(self, name, volume): + self.calls.append(("set_sink_volume", name, round(volume, 3))) + + def set_sink_mute(self, name, muted): + self.calls.append(("set_sink_mute", name, muted)) + + def move_stream(self, serial, sink_name): + self.calls.append(("move_stream", serial, sink_name)) + + def node_id(self, name, retries=20): + self.calls.append(("node_id", name)) + return self.node_ids.get(name) + + def wpctl(self, *args): + self.calls.append(("wpctl",) + args) + + def ports(self, direction_flag, node_name): + return self.port_map.get((direction_flag, node_name), []) + + def link(self, src_port, dst_port): + self.calls.append(("link", src_port, dst_port)) + return True + + def audio_streams(self): + return list(self.streams) + + def default_sink(self): + return self.default + + def set_default_sink(self, name): + self.calls.append(("set_default_sink", name)) + + def spawn_loopback(self, argv, detach): + self.calls.append(("spawn", argv, detach)) + if self.spawn_fails: + return None + proc = FakeProc(argv) + self.spawned.append(proc) + return proc + + def sweep_stale_loopbacks(self): + self.calls.append(("sweep",)) + + def find_wave(self): + self.calls.append(("find_wave",)) + return (None, None) diff --git a/tests/test_mixer_reconcile.py b/tests/test_mixer_reconcile.py new file mode 100644 index 0000000..2ddb844 --- /dev/null +++ b/tests/test_mixer_reconcile.py @@ -0,0 +1,197 @@ +"""The mixer's decisions about the graph, exercised against a fake of it. + +The reconcile and spawn paths are where the worst regressions have lived -- +double-routed audio, loopbacks against dead links, faders driving nothing -- +and until the PipeWire seam nothing could test them: they were ~30 scattered +subprocess calls. With Mixer(pw=FakePipeWire()) they are call-sequence +assertions: configure what the graph holds, run one reconcile, read back what +the mixer decided to do about it. +""" + +import unittest +from unittest import mock + +from wavexlr import mixer as mixer_mod +from .support import FakePipeWire, bare_mixer, temp_config + +MIXES = { + "personal": {"id": "personal", "name": "Personal Mix", + "sink": "openwave_personal_mix"}, + "chat": {"id": "chat", "name": "Chat Mix", "sink": "openwave_chat_mix"}, +} +ARCTIS = "alsa_input.usb-Arctis-00.mono-fallback" + + +class Base(unittest.TestCase): + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self.addCleanup(self._ctx.__exit__, None, None, None) + self.pw = FakePipeWire() + self.mx = bare_mixer(_pw=self.pw, _mixes=dict(MIXES)) + # _link_capture polls for ports with real sleeps; a fake graph is + # instantaneous, so waiting on it is only wasted wall-clock. + ctx = mock.patch.object(mixer_mod.time, "sleep", lambda _s: None) + ctx.start() + self.addCleanup(ctx.stop) + + def loop_name(self, source_id="dock", mix_id="personal"): + return self.mx._capture_loopback_name(source_id, mix_id) + + +class CaptureCells(Base): + def setUp(self): + super().setUp() + self.mx._sources = {"dock": {"id": "dock", "name": "Dock", + "node_name": ARCTIS, "level": 1.0}} + self.mx._live_captures = frozenset({ARCTIS}) + + def test_a_live_cell_spawns_its_loopback_and_sets_its_level(self): + name = self.loop_name() + self.pw.node_ids[name] = "77" + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.assertEqual(len(self.pw.spawned), 1) + self.assertIn(("wpctl", "set-volume", "77", "0.800"), self.pw.calls) + self.assertIn(("wpctl", "set-mute", "77", "0"), self.pw.calls) + + def test_the_cell_fader_composes_with_the_source_trim(self): + """cell x trim is the whole level model; the graph gets the product.""" + self.mx._sources["dock"]["level"] = 0.5 + self.pw.node_ids[self.loop_name()] = "77" + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.assertIn(("wpctl", "set-volume", "77", "0.400"), self.pw.calls) + + def test_a_muted_source_silences_the_cell_without_tearing_it_down(self): + self.mx._sources["dock"]["muted"] = True + self.pw.node_ids[self.loop_name()] = "77" + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.assertIn(("wpctl", "set-volume", "77", "0.000"), self.pw.calls) + self.assertEqual(len(self.pw.spawned), 1) + + def test_a_zero_cell_tears_the_loopback_down(self): + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + proc = self.pw.spawned[0] + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.0, False) + self.assertTrue(proc.terminated) + self.assertNotIn(("dock", "personal"), self.mx._procs) + + def test_an_absent_capture_node_is_not_looped_from(self): + """The device vanished; a loopback would capture nothing forever.""" + self.mx._live_captures = frozenset({"some_other_node"}) + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.assertEqual(self.pw.spawned, []) + + def test_a_second_reconcile_does_not_spawn_a_second_loopback(self): + self.pw.node_ids[self.loop_name()] = "77" + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.6, False) + self.assertEqual(len(self.pw.spawned), 1) + + def test_the_volume_is_reapplied_every_pass(self): + """The immune-by-construction claim from the cryo-port spec: no cached + cell state, so a spawn that failed and was retried still ends at the + right level. This is the property 07579a1 existed to patch around.""" + name = self.loop_name() + self.pw.node_ids[name] = "77" + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + sets = [c for c in self.pw.calls + if c[:2] == ("wpctl", "set-volume") and c[2] == "77"] + self.assertEqual(len(sets), 2) + + +class SpawnAndLink(Base): + def test_the_capture_side_is_linked_port_by_port(self): + self.pw.port_map[("-o", ARCTIS)] = [f"{ARCTIS}:capture_1"] + loop = "openwave_loop_test" + self.pw.port_map[("-i", f"{loop}_cap")] = [ + f"{loop}_cap:input_FL", f"{loop}_cap:input_FR"] + self.mx._spawn_loopback(("k",), ARCTIS, "openwave_personal_mix", loop) + links = [c for c in self.pw.calls if c[0] == "link"] + # Mono source, stereo capture: the one port feeds both inputs. + self.assertEqual(links, [ + ("link", f"{ARCTIS}:capture_1", f"{loop}_cap:input_FL"), + ("link", f"{ARCTIS}:capture_1", f"{loop}_cap:input_FR"), + ]) + + def test_a_failed_spawn_leaves_no_bookkeeping(self): + """A key with no process would block every future respawn.""" + self.pw.spawn_fails = True + self.mx._spawn_loopback(("k",), ARCTIS, "sink", "openwave_loop_test") + self.assertEqual(self.mx._procs, {}) + + def test_the_loopback_carries_its_label(self): + """Unlabelled it shows as pw-loopback- in every mixer tool.""" + self.mx._spawn_loopback(("k",), ARCTIS, "sink", "openwave_loop_test", + description="Dock → Personal Mix") + argv = self.pw.spawned[0].argv + self.assertIn("Dock → Personal Mix", " ".join(argv)) + + +class ReapingTheDead(Base): + def test_a_dead_loopback_frees_its_key_for_respawn(self): + """PipeWire restarted under the child: the stale key must not block + _spawn_loopback forever.""" + self.mx._spawn_loopback(("k",), ARCTIS, "sink", "openwave_loop_test") + self.pw.spawned[0].dies() + self.mx._reap_dead() + self.assertEqual(self.mx._procs, {}) + + def test_a_living_loopback_is_left_alone(self): + self.mx._spawn_loopback(("k",), ARCTIS, "sink", "openwave_loop_test") + self.mx._reap_dead() + self.assertIn(("k",), self.mx._procs) + + +class RestoringThroughTheSeam(Base): + def test_the_masters_are_applied_via_the_adapter(self): + """The whole restore path, asserted on graph calls rather than on + patched module functions.""" + self.mx._volumes_restored = False + self.mx.remember_mix_volume("personal", 0.62, False) + self.pw.volumes = {"openwave_personal_mix": (1.0, False), + "openwave_chat_mix": (1.0, False)} + self.assertTrue(self.mx.restore_mix_volumes()) + self.assertIn(("set_sink_volume", "openwave_personal_mix", 0.62), + self.pw.calls) + self.assertIn(("set_sink_mute", "openwave_personal_mix", False), + self.pw.calls) + + +if __name__ == "__main__": + unittest.main() + + +class RedetectingTheDevice(unittest.TestCase): + """mic/hp were resolved once, in __init__: a Wave plugged in after launch + stayed None forever, so monitoring pointed at nothing while the USB side + reconnected fine.""" + + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self.addCleanup(self._ctx.__exit__, None, None, None) + self.pw = FakePipeWire() + self.mx = bare_mixer(_pw=self.pw) + self.mx.mic = None + self.mx.hp = None + + def test_a_wave_that_appeared_is_adopted(self): + self.pw.find_wave = lambda: ("alsa_input.usb-Wave-00.mono", + "alsa_output.usb-Wave-00.stereo") + self.assertTrue(self.mx.redetect_device()) + self.assertEqual(self.mx.mic, "alsa_input.usb-Wave-00.mono") + + def test_an_unchanged_answer_reconciles_nothing(self): + """Called from every successful connect, so the common case -- same + device, same nodes -- must not queue a graph pass.""" + self.mx.mic = "alsa_input.usb-Wave-00.mono" + self.mx.hp = "alsa_output.usb-Wave-00.stereo" + self.pw.find_wave = lambda: (self.mx.mic, self.mx.hp) + self.assertFalse(self.mx.redetect_device()) + + def test_a_changed_device_queues_a_reconcile(self): + self.mx._started = True # redetect happens on a running mixer + self.pw.find_wave = lambda: ("alsa_input.usb-Wave-00.mono", None) + self.mx.redetect_device() + self.assertTrue(self.mx._pending) diff --git a/wavexlr/app.py b/wavexlr/app.py index 03cb8a3..a624f2e 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -572,6 +572,9 @@ def _connect(): pass return {"state": self.dev.get_all(), "info": info} def _done(result): + # A Wave that appeared after the mixer was built: mic/hp were + # resolved to None then, and only a re-detect corrects them. + self.mixer.redetect_device() self._apply_profile(self.dev.profile) self._apply_state(result["state"]) info = result["info"] diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 8bfe29f..d7e7cef 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -130,6 +130,113 @@ def _move_stream(serial, sink_name): pass +def _pw_link(src_port, dst_port): + """Wire one output port to one input port. True unless pw-link is gone.""" + try: + subprocess.run( + ["pw-link", src_port, dst_port], + capture_output=True, text=True, timeout=2, + ) + return True + except (FileNotFoundError, subprocess.SubprocessError): + return False + + +def _set_default_sink(name): + try: + subprocess.run(["pactl", "set-default-sink", name], + capture_output=True, timeout=3) + except (FileNotFoundError, subprocess.SubprocessError): + pass + + +def _spawn_loopback_proc(argv, detach): + """Start a pw-loopback, or None if it cannot start.""" + try: + return subprocess.Popen( + argv, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + preexec_fn=None if detach else _set_pdeathsig, + start_new_session=detach, + ) + except (FileNotFoundError, OSError): + return None + + +def _pkill_stale_loopbacks(): + try: + subprocess.run( + # Broader than openwave_loop_: mix capture sources are named + # after their sink, so a narrower pattern would leak one per + # unclean exit. + ["pkill", "-f", "pw-loopback.*openwave_"], + capture_output=True, timeout=2, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return + time.sleep(0.2) # give the kernel a beat to reap so we don't race + + +class SubprocessPipeWire: + """The live PipeWire graph, spoken to through the pactl/wpctl/pw-* CLIs. + + This is the seam between the mixer's decisions and the machine's audio. + Every method delegates to the module-level implementation it names, at + call time, so tests that patch those functions keep intercepting the real + adapter -- while a fake implementing this surface lets the reconcile and + spawn logic be exercised with no PipeWire, no subprocesses and no sound + card at all, which is exactly the layer the worst regressions have lived + in. Shaped after CryoByte33/openwave's SubprocessPipeWire, with methods + matching this mixer's own call shapes. + """ + + def short_list(self, kind): + return _pactl_short(kind) + + def sink_volumes(self): + return _pactl_sink_volumes() + + def set_sink_volume(self, name, volume): + _pactl_set_sink_volume(name, volume) + + def set_sink_mute(self, name, muted): + _pactl_set_sink_mute(name, muted) + + def move_stream(self, serial, sink_name): + _move_stream(serial, sink_name) + + def node_id(self, name, retries=20): + return _node_id_by_name(name, retries) + + def wpctl(self, *args): + _wpctl(*args) + + def ports(self, direction_flag, node_name): + return _ports(direction_flag, node_name) + + def link(self, src_port, dst_port): + return _pw_link(src_port, dst_port) + + def audio_streams(self): + return list_audio_streams() + + def default_sink(self): + return _default_sink_name() + + def set_default_sink(self, name): + _set_default_sink(name) + + def spawn_loopback(self, argv, detach): + return _spawn_loopback_proc(argv, detach) + + def sweep_stale_loopbacks(self): + _pkill_stale_loopbacks() + + def find_wave(self): + return find_wave_xlr_alsa() + + def _is_output_key(key): """True for a mix's output loopback, which outlives this process.""" return isinstance(key, tuple) and len(key) == 2 and key[0] == "output" @@ -622,7 +729,10 @@ def claim_streams(sources, streams): class Mixer: """Manages pw-loopback subprocesses for the matrix's mic row.""" - def __init__(self): + def __init__(self, pw=None): + # The PipeWire seam. Everything the mixer does to the graph goes + # through this; a test hands in a fake and asserts on the calls. + self._pw = pw or SubprocessPipeWire() self._lock = Lock() self._procs = {} self._state = self._load_state() @@ -644,7 +754,7 @@ def __init__(self): # set_sources/set_mixes stay silent until it has run once. self._started = False self._volumes_restored = False - self.mic, self.hp = find_wave_xlr_alsa() + self.mic, self.hp = self._pw.find_wave() # Background worker: every operation that talks to pw-loopback / # pw-cli / wpctl runs here so the GTK main thread never blocks on a @@ -865,7 +975,7 @@ def resolve_output(self, mix_id, sinks=None, default_sink=None): return self.hp if default_sink is None: - default_sink = _default_sink_name() + default_sink = self._pw.default_sink() if default_sink and default_sink in eligible: return default_sink @@ -924,38 +1034,33 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, ident = f'application.name=OpenWave node.description="{label}" ' cap_ident = f'application.name=OpenWave node.description="{label} (capture)" ' - try: - proc = subprocess.Popen( - [ - "pw-loopback", - "--capture-props=" - f"node.autoconnect=false node.name={capture_node_name} " - + cap_ident + - "audio.channels=2 audio.position=[FL,FR]", - "--playback-props=" - + (f"target.object={playback_target} " if playback_target else "") - + f"node.name={node_name} " - + ("" if "node.description" in playback_extra else ident) - + playback_extra + - "audio.channels=2 audio.position=[FL,FR]", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - preexec_fn=None if detach else _set_pdeathsig, - start_new_session=detach, - ) - except (FileNotFoundError, OSError): + proc = self._pw.spawn_loopback( + [ + "pw-loopback", + "--capture-props=" + f"node.autoconnect=false node.name={capture_node_name} " + + cap_ident + + "audio.channels=2 audio.position=[FL,FR]", + "--playback-props=" + + (f"target.object={playback_target} " if playback_target else "") + + f"node.name={node_name} " + + ("" if "node.description" in playback_extra else ident) + + playback_extra + + "audio.channels=2 audio.position=[FL,FR]", + ], + detach, + ) + if proc is None: return self._procs[key] = proc self._link_capture(capture_source_name, capture_node_name) - @staticmethod - def _link_capture(source_node_name, capture_node_name, retries=20): + def _link_capture(self, source_node_name, capture_node_name, retries=20): """Wire each output port of `source_node_name` to a corresponding input port of `capture_node_name`. Mono → stereo duplicates.""" for _ in range(retries): - src_ports = _ports("-o", source_node_name) - dst_ports = _ports("-i", capture_node_name) + src_ports = self._pw.ports("-o", source_node_name) + dst_ports = self._pw.ports("-i", capture_node_name) if src_ports and dst_ports: break time.sleep(0.05) @@ -963,12 +1068,7 @@ def _link_capture(source_node_name, capture_node_name, retries=20): return for i, dst in enumerate(dst_ports): src = src_ports[i % len(src_ports)] - try: - subprocess.run( - ["pw-link", src, dst], - capture_output=True, text=True, timeout=2, - ) - except (FileNotFoundError, subprocess.SubprocessError): + if not self._pw.link(src, dst): return def _destroy_loopback(self, key): @@ -1122,7 +1222,7 @@ def poll_streams(self): """Refresh the active-stream cache; reconcile on worker if anything moved. Returns (added, removed) stream-id sets for the caller's bookkeeping.""" - new = {s["id"]: s for s in list_audio_streams()} + new = {s["id"]: s for s in self._pw.audio_streams()} with self._lock: added = set(new) - set(self._streams) removed = set(self._streams) - set(new) @@ -1194,7 +1294,7 @@ def restore_mix_volumes(self): # back onto can hold the gate shut: one with nothing remembered has # nothing to lose, and waiting on it would mean a first run never # starts observing at all. - live = _pactl_sink_volumes() + live = self._pw.sink_volumes() for mix_id, mix in list(self._mixes.items()): sink = mix.get("sink") if not sink or self.mix_volume(mix_id) is None: @@ -1207,8 +1307,8 @@ def restore_mix_volumes(self): if not sink or remembered is None: continue volume, muted = remembered - _pactl_set_sink_volume(sink, volume) - _pactl_set_sink_mute(sink, muted) + self._pw.set_sink_volume(sink, volume) + self._pw.set_sink_mute(sink, muted) self._volumes_restored = True return True @@ -1229,7 +1329,7 @@ def observe_mix_volumes(self): """ if not self._volumes_restored: return - live = _pactl_sink_volumes() + live = self._pw.sink_volumes() if not live: return for mix_id, mix in list(self._mixes.items()): @@ -1317,7 +1417,7 @@ def _do_start(self): self._respawn_mix_sources() self._respawn_all_output_loopbacks() with self._lock: - self._streams = {s["id"]: s for s in list_audio_streams()} + self._streams = {s["id"]: s for s in self._pw.audio_streams()} # Outside the lock above: _refresh_live_captures takes it itself. self._refresh_live_captures() self._started = True @@ -1337,11 +1437,11 @@ def _pin_unity(self, node_name): hand, or by anything walking the graph -- silences that path on every launch afterwards, with the routing looking perfectly correct. """ - node_id = _node_id_by_name(node_name) + node_id = self._pw.node_id(node_name) if node_id is None: return - _wpctl("set-volume", node_id, "1.0") - _wpctl("set-mute", node_id, "0") + self._pw.wpctl("set-volume", node_id, "1.0") + self._pw.wpctl("set-mute", node_id, "0") def _respawn_output_loopback(self, mix_id, sinks=None, default_sink=None): """(Re)create one mix's output loopback for its current target.""" @@ -1378,7 +1478,7 @@ def _rescue_default_sink(self): priority.session=0 makes it unlikely; this makes it recoverable. """ - default = _default_sink_name() + default = self._pw.default_sink() if not default or not default.startswith(SOURCE_SINK_PREFIX): return with self._lock: @@ -1387,8 +1487,7 @@ def _rescue_default_sink(self): if target is None: return try: - subprocess.run(["pactl", "set-default-sink", target], - capture_output=True, timeout=3) + self._pw.set_default_sink(target) except (FileNotFoundError, subprocess.SubprocessError): return @@ -1437,7 +1536,7 @@ def _respawn_mix_sources(self): def _respawn_all_output_loopbacks(self): """Retarget every mix, paying the sink-enumeration cost once.""" sinks = list_output_sinks() - default_sink = _default_sink_name() + default_sink = self._pw.default_sink() with self._lock: mix_ids = list(self._mixes) for mix_id in mix_ids: @@ -1499,19 +1598,26 @@ def _sweep_orphan_source_sinks(self): if name not in known: setup.destroy_mix_sink(name) - @staticmethod - def _sweep_stale_loopbacks(): - try: - subprocess.run( - # Broader than openwave_loop_: mix capture sources are named - # after their sink, so a narrower pattern would leak one per - # unclean exit. - ["pkill", "-f", "pw-loopback.*openwave_"], - capture_output=True, timeout=2, - ) - except (FileNotFoundError, subprocess.SubprocessError): - return - time.sleep(0.2) # give the kernel a beat to reap so we don't race + def _sweep_stale_loopbacks(self): + self._pw.sweep_stale_loopbacks() + + def redetect_device(self): + """Re-resolve which ALSA nodes are the Wave's mic and headphones. + + The lookup used to run exactly once, in __init__, so a Mixer + constructed with no Wave present kept mic=hp=None until restart -- + the app could reconnect over USB, but monitoring stayed pointed at + nothing. Node names embed the USB serial and survive a replug, so a + re-detect is only needed when the answer was missing or the device + actually changed; both are cheap to ask. Called from the connect + path, on the worker, since the answer comes from the graph. + """ + mic, hp = self._pw.find_wave() + if (mic, hp) == (self.mic, self.hp): + return False + self.mic, self.hp = mic, hp + self._push_reconcile() + return True # ----- internal ----- def _reap_dead(self): @@ -1615,13 +1721,13 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted key, capture_node, mix_sink, node_name, description=f"{src_name} \u2192 {mix_name}", ) - node_id = _node_id_by_name(node_name) + node_id = self._pw.node_id(node_name) if node_id is not None: # cell fader x source trim: the row slider scales this source # everywhere, the cell decides how much of it this mix gets. - _wpctl("set-volume", node_id, - f"{volume * self._source_gain(source_id):.3f}") - _wpctl("set-mute", node_id, "1" if muted else "0") + self._pw.wpctl("set-volume", node_id, + f"{volume * self._source_gain(source_id):.3f}") + self._pw.wpctl("set-mute", node_id, "1" if muted else "0") def _reconcile_app_cell(self, source_id, mix_id, volume, muted): """Route an application source into one mix. @@ -1662,7 +1768,7 @@ def _reconcile_app_cell(self, source_id, mix_id, volume, muted): stream = streams.get(stream_id) or {} serial = stream.get("serial") if serial is not None: - _move_stream(serial, intake) + self._pw.move_stream(serial, intake) # One loopback per (source, mix), not per stream: every stream for this # source shares the intake sink, so they share the path out of it and @@ -1678,13 +1784,13 @@ def _reconcile_app_cell(self, source_id, mix_id, volume, muted): key, intake, mix_sink, node_name, description=f"{source.get('name', source_id)} \u2192 {mix_name}", ) - node_id = _node_id_by_name(node_name) + node_id = self._pw.node_id(node_name) if node_id is not None: # cell fader x source trim: the row slider scales this source # everywhere, the cell decides how much of it this mix gets. - _wpctl("set-volume", node_id, - f"{volume * self._source_gain(source_id):.3f}") - _wpctl("set-mute", node_id, "1" if muted else "0") + self._pw.wpctl("set-volume", node_id, + f"{volume * self._source_gain(source_id):.3f}") + self._pw.wpctl("set-mute", node_id, "1" if muted else "0") def _source_is_routed(self, source_id): """True if any mix carries this source above zero. From 2844b3f35e41a53c62c99c76308bc43612a183a8 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:55:11 -0500 Subject: [PATCH 62/99] Cut versioned releases with installable objects from a tag The Stream Deck plugin computes its versions with semantic-release from Angular commit messages; this repository writes prose subjects, which semantic-release reads as "never release anything". So the adaptation keeps everything downstream of the version and moves only its origin: the version is the tag. Push v1.2.3 and the workflow gates on the test suite, builds the release objects, and publishes the GitHub Release with generated notes. The objects are a source tarball, sha256sums, and a .deb -- the two distributions install.sh serves that have no packaging of their own get `sudo apt install ./openwave_*.deb` instead of a curl pipe. Its Depends mirrors install.sh's apt list, python3-xlib is Recommends to match its strictly-optional status, and SITEPKG is pinned to /usr/lib/python3/dist-packages: the Makefile asks the interpreter, and Ubuntu's system python answers /usr/local/... first -- the right path for a live install on that machine, the wrong one inside a package. The same interpreter-vs-PREFIX gap that split the module and its data in e3988fd, caught at packaging time here. workflow_dispatch builds the artifacts without publishing, so the build steps can be exercised without spending a version number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- .github/workflows/release.yml | 128 ++++++++++++++++++++++++++++++++++ README.md | 4 ++ 2 files changed, 132 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7901a9c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +name: Release + +# Tag-driven semver, adapted from the Stream Deck plugin's release flow. +# That repo computes versions with semantic-release from Angular commit +# messages; this one writes prose commit subjects, which semantic-release +# reads as "never release". So the version is the tag -- push v1.2.3 and +# this builds the release objects -- and everything downstream of the +# version (gate on the suite, build artifacts, publish a Release) is as +# automatic as the plugin's. +# +# git tag v1.2.3 && git push v1.2.3 + +on: + push: + tags: ["v[0-9]+.[0-9]+.[0-9]+*"] + # Buildable on demand so the artifact steps can be exercised without + # spending a version number; a dispatch run uploads workflow artifacts + # but publishes no Release. + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: Gate on the suite + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install libusb + run: sudo apt-get update -qq && sudo apt-get install -y -qq libusb-1.0-0 + - name: Run unit tests + run: python -m unittest discover -s tests -t . -v + - name: Byte-compile every module + run: python -m compileall -q wavexlr tests + + build: + name: Build release objects + needs: [test] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: version + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + else + echo "version=0.0.0-dev.${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + fi + + - name: Source tarball + run: | + V="${{ steps.version.outputs.version }}" + git archive --format=tar.gz --prefix="openwave-${V}/" \ + -o "openwave-${V}.tar.gz" HEAD + + # A .deb built on the runner installs into the runner distribution's + # own site-packages path (the Makefile asks the interpreter), which is + # the right path for the systems that will install the .deb. Depends + # mirrors install.sh's apt list; python3-xlib is Recommends, matching + # its strictly-optional status. + - name: Debian package + run: | + V="${{ steps.version.outputs.version }}" + PKG="openwave_${V}_all" + # SITEPKG pinned: the Makefile asks the interpreter, and Ubuntu's + # system python answers /usr/local/... first -- the right path for + # a live install on that machine, the wrong one inside a package. + make install DESTDIR="$PWD/$PKG" PREFIX=/usr \ + SITEPKG=/usr/lib/python3/dist-packages + mkdir -p "$PKG/DEBIAN" + cat > "$PKG/DEBIAN/control" <= 3.10), python3-gi, gir1.2-gtk-4.0, gir1.2-adw-1, libadwaita-1-0, libusb-1.0-0, pipewire + Recommends: python3-xlib + Maintainer: OpenWave contributors + Homepage: https://github.com/rikkichy/openwave + Description: The audio mixing matrix for Linux + Per-app mixes with per-mix outputs, plus native control of Elgato + Wave hardware - the Wave XLR interface (original and MK.2/XLR + Dock) and the Wave:3 microphone. + EOF + dpkg-deb --build --root-owner-group "$PKG" + + - name: Checksums + run: sha256sum openwave-*.tar.gz openwave_*.deb > sha256sums.txt + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: release-objects + path: | + openwave-*.tar.gz + openwave_*.deb + sha256sums.txt + + publish: + name: Publish Release + needs: [build] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-objects + - name: Create Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF#refs/tags/}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "OpenWave ${GITHUB_REF#refs/tags/v}" \ + --generate-notes \ + openwave-*.tar.gz openwave_*.deb sha256sums.txt diff --git a/README.md b/README.md index 2ba2584..55f908e 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,10 @@ a Stream Deck plugin built on this. ## Install +Tagged releases on the [Releases page](../../releases) carry ready-made +objects: a `.deb` for Debian/Ubuntu (`sudo apt install ./openwave_*.deb`), +a source tarball, and checksums. + One-liner — detects Arch, Debian/Ubuntu, Fedora, openSUSE, or Void; installs deps and OpenWave: ```bash From 88b74db22bd93b4edf0c5414a76dea04c0e001bf Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 12:58:41 -0500 Subject: [PATCH 63/99] Green the suite on a runner that has no PyGObject CI installs no Python dependencies on purpose -- the suite is meant to run with no GTK, no audio server, no hardware -- and it has been red for a while without anyone noticing, because every development machine has gi and the suite passes there. TrayHostProbe and MeterSilence import GLib-backed modules and have erred on every CI run since they were written; today's icon and throttler tests added more of the same shape. Three fixes, by what each module actually needs. icons.py imports gi lazily inside _theme(), since the module travels wherever icon names are handled and its no-display path must work with no display stack at all. scheduler.py imports GLib inside GLibScheduler, so the Throttler stays the pure-Python thing the tests exercise. And the test classes that genuinely cannot run without GLib -- a D-Bus probe cannot even fake its bus without GLib.Variant -- skip with a reason instead of erroring. Verified in both worlds: the full suite with gi present, and the suite with gi hidden behind an ImportError shim, which is the CI runner's reality -- OK with seven skips, where it errored before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/test_recovery.py | 20 +++++++++++++++++++- tests/test_tray.py | 12 ++++++++++-- wavexlr/icons.py | 14 ++++++++++---- wavexlr/scheduler.py | 18 +++++++++++++----- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/tests/test_recovery.py b/tests/test_recovery.py index 61f0dde..3f7ce98 100644 --- a/tests/test_recovery.py +++ b/tests/test_recovery.py @@ -10,6 +10,16 @@ from wavexlr import recovery + +def _has_gi(): + try: + import gi # noqa: F401 + return True + except ImportError: + return False + + + DOCK = ("alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9A40411NOP9-00" ".mono-fallback") @@ -162,6 +172,7 @@ def test_an_unknown_card_is_not_touched(self): unittest.main() +@unittest.skipUnless(_has_gi(), "PyGObject not available") class MeterSilence(unittest.TestCase): """`silent_for` is the input the whole decision rests on.""" @@ -197,8 +208,15 @@ def poll(self): self.assertIsNone(self.meter.silent_for("dock")) +@unittest.skipUnless(_has_gi(), "PyGObject not available") class TrayHostProbe(unittest.TestCase): - """Whether a tray exists decides whether hiding the window is safe.""" + """Whether a tray exists decides whether hiding the window is safe. + + Skipped without PyGObject: the probe under test answers a D-Bus call, + which cannot even be faked without GLib.Variant. This class has been the + one red light on a runner that deliberately installs no GTK -- since the + probe was written, not noticed because every dev machine has gi. + """ def _probe(self, answer): import gi diff --git a/tests/test_tray.py b/tests/test_tray.py index 98d7f07..b5312d8 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -13,8 +13,16 @@ import unittest -from wavexlr import tray -from wavexlr.app import WaveXLRWindow +# The CI runner installs no PyGObject on purpose -- the suite is meant to run +# with no GTK, no audio server and no hardware -- and tray.py's D-Bus surface +# is GLib through and through. The reducer itself is pure, but it lives in a +# module that cannot load without gi, so without gi this file politely +# excuses itself instead of erroring. +try: + from wavexlr import tray + from wavexlr.app import WaveXLRWindow +except ImportError as exc: + raise unittest.SkipTest(f"PyGObject not available: {exc}") class TheRule(unittest.TestCase): diff --git a/wavexlr/icons.py b/wavexlr/icons.py index 18700b8..af1234c 100644 --- a/wavexlr/icons.py +++ b/wavexlr/icons.py @@ -15,10 +15,10 @@ change in either direction, and needs no migration. """ -import gi - -gi.require_version("Gtk", "4.0") -from gi.repository import Gdk, Gtk # noqa: E402 +# gi is imported inside _theme(), not here: resolve() is called from GTK +# code, but the module is imported wherever icon names are handled, including +# headless contexts (the test runner installs no PyGObject at all), and the +# no-display path must work without it. # Preferred name -> names to try when the active theme does not have it, best # first. Every alternative here was checked against Breeze; the preferred name @@ -62,6 +62,12 @@ def _theme(): """The display's icon theme, or None when there is no display yet.""" global _watched + try: + import gi + gi.require_version("Gtk", "4.0") + from gi.repository import Gdk, Gtk + except (ImportError, ValueError): + return None display = Gdk.Display.get_default() if display is None: return None diff --git a/wavexlr/scheduler.py b/wavexlr/scheduler.py index 8bb8961..dbc0f90 100644 --- a/wavexlr/scheduler.py +++ b/wavexlr/scheduler.py @@ -22,29 +22,37 @@ import threading -from gi.repository import GLib +# GLib is imported inside GLibScheduler, not here: the Throttler is pure +# Python and gets exercised on runners that install no PyGObject at all, +# and a module-level import would take it down with the production half. class GLibScheduler: """Production scheduler: GLib timeouts + worker threads marshalled via idle_add.""" + def __init__(self): + from gi.repository import GLib + self._glib = GLib + def run_async(self, fn, on_done=None, on_error=None): + glib = self._glib + def _worker(): try: result = fn() if on_done is not None: - GLib.idle_add(on_done, result) + glib.idle_add(on_done, result) except Exception as e: if on_error is not None: - GLib.idle_add(on_error, e) + glib.idle_add(on_error, e) threading.Thread(target=_worker, daemon=True).start() def call_every(self, interval_s, fn): - return GLib.timeout_add(int(interval_s * 1000), fn) + return self._glib.timeout_add(int(interval_s * 1000), fn) def cancel(self, handle): if handle is not None: - GLib.source_remove(handle) + self._glib.source_remove(handle) class Throttler: From 3d8c1bf0781d178fc65c76b53a46ed0bdb296d98 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 13:11:19 -0500 Subject: [PATCH 64/99] Do not let a hidden window overwrite the remembered geometry Quit usually arrives via the tray, and closing hid the window first -- so _save_ui_state ran on a hidden window, which reports 0x0, and wrote the zeros through. The restore guard then discarded them and the window came back at the 1360px default, which clips the third mix column: a matrix sized by hand snapping back to "half opened" on every hide-then-quit. The maximized case already knew a window can lie about its size and kept the previous answer; the hidden case now gets the same treatment. Tested on a stub window, alongside the maximized rule, which had no test either. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- tests/test_tray.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ wavexlr/app.py | 11 ++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/test_tray.py b/tests/test_tray.py index b5312d8..0dd3c84 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -131,3 +131,48 @@ def test_application_rows_are_not_capture_rows(self): if __name__ == "__main__": unittest.main() + + +class RememberedGeometry(unittest.TestCase): + """_save_ui_state on a window that is lying about its size. + + Quit arrives via the tray with the window hidden, and a hidden GTK + window reports 0x0. Writing that through destroyed the remembered + geometry; the restore guard then discarded it and the window came back + at the 1360px default, clipping the matrix -- read as "stuck half + opened". + """ + + def save(self, width, height, maximized=False, previous=None): + import json + import os + import tempfile + with tempfile.TemporaryDirectory() as tmp: + stub = type("W", (), {})() + stub._UI_STATE = os.path.join(tmp, "ui-state.json") + if previous: + with open(stub._UI_STATE, "w") as f: + json.dump(previous, f) + stub.get_width = lambda: width + stub.get_height = lambda: height + stub.is_maximized = lambda: maximized + stub._load_ui_state = lambda: ( + WaveXLRWindow._load_ui_state(stub)) + stub.gain_lock = None + stub._offered_nodes = set() + WaveXLRWindow._save_ui_state(stub) + with open(stub._UI_STATE) as f: + return json.load(f) + + def test_an_honest_size_is_recorded(self): + state = self.save(1900, 1100) + self.assertEqual((state["width"], state["height"]), (1900, 1100)) + + def test_a_hidden_window_does_not_destroy_the_remembered_size(self): + state = self.save(0, 0, previous={"width": 1900, "height": 1100}) + self.assertEqual((state["width"], state["height"]), (1900, 1100)) + + def test_a_maximized_window_keeps_the_unmaximized_size(self): + state = self.save(2560, 1440, maximized=True, + previous={"width": 1900, "height": 1100}) + self.assertEqual((state["width"], state["height"]), (1900, 1100)) diff --git a/wavexlr/app.py b/wavexlr/app.py index a624f2e..8a37cee 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -172,9 +172,14 @@ def _save_ui_state(self): getattr(self, "gain_lock", None) and self.gain_lock.get_active() ), } - if state["maximized"]: - # get_width/height report the maximized size; keep the last - # restored size so unmaximizing does not snap to full screen. + if state["maximized"] or state["width"] <= 0 or state["height"] <= 0: + # Two windows lie about their size: a maximized one reports + # the screen, and a hidden one reports 0x0 -- which is what + # this window is when quit arrives via the tray, since + # closing hid it first. Writing the zeros through destroyed + # the remembered geometry, and the restore guard then fell + # back to GTK's minimum: a cramped window that clips the + # matrix. Keep the last honest answer instead. previous = self._load_ui_state() state["width"] = previous.get("width", state["width"]) state["height"] = previous.get("height", state["height"]) From ce02a62c134faa8aea8a6b69151fd4508e5a6dbc Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 13:29:28 -0500 Subject: [PATCH 65/99] Build the Arch package from this fork's releases, via the Makefile The PKGBUILD pointed at upstream's v1.0.0 tarball with a SKIP checksum, so it packaged a tree from before the MK.2 support, and its hand-written package() had drifted besides: no daemon launcher, no icons, none of the modules added since it was written. It now sources the release object this repository actually publishes, pinned by sha256, and package() delegates to the Makefile -- the one description of the install layout, so the next added file cannot be forgotten here. A check() runs the suite during build, which is only safe since the tests stopped touching the builder's real configuration. Verified with makepkg against the published v1.1.0 asset: builds, suite passes in check(), and the package carries everything the old one dropped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BfPcc6rq3USP5u38bmXRCC --- PKGBUILD | 51 +++++++++++++++------------------------------------ 1 file changed, 15 insertions(+), 36 deletions(-) diff --git a/PKGBUILD b/PKGBUILD index e8922c6..0a3f5bd 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,47 +1,26 @@ -# Maintainer: rikkichy +# Maintainer: Zedwil +# Contributor: rikkichy pkgname=openwave -pkgver=1.0.0 +pkgver=1.1.0 pkgrel=1 pkgdesc="The audio mixing matrix for Linux — per-app mixes, per-mix outputs, Elgato Wave control" arch=('any') -url="https://github.com/rikkichy/openwave" +url="https://github.com/NyleGarcia/openwave" license=('MIT') depends=('python' 'python-gobject' 'gtk4' 'libadwaita' 'libusb' 'pipewire') optdepends=('python-xlib: friendly app names in the Add Source picker') -source=("$pkgname-$pkgver.tar.gz::https://github.com/rikkichy/openwave/archive/refs/tags/v$pkgver.tar.gz") -sha256sums=('SKIP') +source=("https://github.com/NyleGarcia/openwave/releases/download/v$pkgver/$pkgname-$pkgver.tar.gz") +sha256sums=('9918ba4c70d685f6f2663390d07fe50f4f98cbb0104e502a91be0bc745322c6c') -package() { +check() { cd "$srcdir/$pkgname-$pkgver" + python -m unittest discover -s tests -t . +} - # Install Python package - local site=$(python3 -c "import site; print(site.getsitepackages()[0])") - install -dm755 "$pkgdir$site/wavexlr" - install -Dm644 wavexlr/*.py "$pkgdir$site/wavexlr/" - install -Dm644 wavexlr/style.css "$pkgdir$site/wavexlr/style.css" - - # Launcher script - install -dm755 "$pkgdir/usr/bin" - printf '#!/bin/sh\nexec python3 -m wavexlr "$@"\n' > "$pkgdir/usr/bin/$pkgname" - chmod 755 "$pkgdir/usr/bin/$pkgname" - - # Desktop entry - install -Dm644 wavexlr.desktop "$pkgdir/usr/share/applications/$pkgname.desktop" - - # Autostart template (user copies to ~/.config/autostart) - install -Dm644 openwave-autostart.desktop "$pkgdir/usr/share/openwave/openwave-autostart.desktop" - - # License - install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" - - # Docs - install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" - - # WirePlumber rule (read by setup.py at first-run, copied to user config) - install -Dm644 wireplumber/51-openwave-wave-xlr.conf \ - "$pkgdir/usr/share/openwave/wireplumber/51-openwave-wave-xlr.conf" - - # PipeWire virtual mix sinks (Personal / Chat / Record) - install -Dm644 pipewire/52-openwave-mixes.conf \ - "$pkgdir/usr/share/openwave/pipewire/52-openwave-mixes.conf" +package() { + cd "$srcdir/$pkgname-$pkgver" + # The Makefile is the one description of the install layout. This file + # used to repeat it by hand and drifted -- it was missing the icons and + # the daemon launcher by the time anyone looked. + make DESTDIR="$pkgdir" PREFIX=/usr install } From 1ae5d0d18be870acf1889c647c97210b8c534b51 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:00 -0500 Subject: [PATCH 66/99] Document the project the way openxlr documents its A full documentation pass modeled on emaspa/openxlr, with credit: a hardware-support matrix with per-control status, a vendor protocol reference derived from device.py/profiles.py and verified with probe, a contributing guide that finally writes down how to cut a release, a backfilled changelog for every tag, and AppStream metainfo so software centers can show what this is. The README gains badges, per-method install sections (the Nix flake was fully built and entirely undocumented), the complete module tree, the full D-Bus action table, and the ~14 shipped features it never mentioned. The 00a6/00b6 confusion is untangled: they are two different MK.2 revisions, and both the device table and the credits now say which one this tree speaks. The stale "arrives in v0.3.0" promise is gone from the shipped PipeWire template, and ARCHITECTURE.md records the PipeWire seam decision that previously lived only in a test docstring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- CHANGELOG.md | 185 +++++++++++++ CONTRIBUTING.md | 103 +++++++ Makefile | 2 + README.md | 455 +++++++++++++++++++++---------- com.github.openwave.metainfo.xml | 55 ++++ docs/ARCHITECTURE.md | 15 + docs/hardware-support.md | 104 +++++++ docs/protocol.md | 108 ++++++++ pipewire/52-openwave-mixes.conf | 4 +- 9 files changed, 887 insertions(+), 144 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 com.github.openwave.metainfo.xml create mode 100644 docs/hardware-support.md create mode 100644 docs/protocol.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f27c23d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,185 @@ +# Changelog + +Notable changes per release. Format follows [Keep a Changelog](https://keepachangelog.com/); +versions are git tags (see [Releases](../../releases)). + +## [Unreleased] + +### Added +- **Multiple Wave devices at once**: every connected Wave — two of the + same model included — is opened, polled at 10 Hz and ALSA-synced; a + Device dropdown in the sidebar picks which one the controls drive, a + sysfs watch notices units appearing or vanishing while others stay + connected, the capture-fix daemon keeps one keepalive pin per device, + scenes record hardware state per serial number, and the tray reports + muted when any device's hardware mute is down. +- **Mix master sliders and output meters**: every mix column header now + carries its master volume slider (throttled, and following external + moves — pavucontrol, media keys, scenes — within a couple of seconds) + and a live level bar tapping the mix sink's monitor. The bar displays + amplitude on the same cubic taper the faders use (a 30% fader is 2.7% + linear amplitude; meter and fader now speak one language) with + peak-hold ballistics (~140 ms decay half-life). +- **Row mute and hardware mute are one mute**: muting an Elgato capture + row flips the device's own mute — from a click, the session bus, a + scene, or a group hand-over, whose losing microphone now goes dark on + its on-air LED too — and the reverse holds: the physical mute button or + a system-side mute reaches the matrix row within a second or two. Rows + pair with USB handles by serial (node-stem fallback when a serial will + not read), so two units of one model each follow their own row, and + only state *changes* propagate, so the pair cannot loop. +- **Devices are discovered while running**: a Wave plugged in + mid-session — or plugged back in after its row was removed — gets its + row within seconds instead of on the next launch. +- **Unplugged device rows can be removed**: a connected Elgato row stays + protected, but once its device is unplugged the row grows a remove + button. Removing it forgets the auto-offer memory for that device, so + plugging it back in brings the row back by itself. +- **Scenes**: every trim, send, mute, output, master and device setting + saved under a name and recalled as one gesture — from a header-bar menu + or four new session-bus actions (`apply-scene`, `save-scene`, + `delete-scene`, `scenes`). Partial recall is normal: entries naming + removed sources or mixes are skipped and reported, and the gain lock + wins over a scene's gain. +- **Diagnostics export**: one file for bug reports — versions, device + state, udev/service status, journal tail, OpenWave's PipeWire nodes — + via an in-app button or `python3 -m wavexlr.diag`. Config contents and + app names stay out unless `--full`. + +### Changed +- The Arch package (PKGBUILD) now builds from this fork's release tarballs + via `make install`, so it ships the icons and the daemon launcher it had + drifted away from. +- One tag push now does everything: `release.yml` publishes the Release, + points the PKGBUILD at the released tarball (pkgver + checksum) and + pushes to AUR; the overlapping manual `build.yml` flow is gone. +- Documentation overhaul: hardware support matrix, protocol reference, + contributing guide, this changelog. + +### Changed (performance) +- Hardware polling no longer forks two `amixer` processes per device ten + times a second: the ALSA read-back runs every fifth poll (still well + under a second of latency for a pavucontrol move), cutting forty + subprocess spawns per second to eight on a two-device setup. +- Meters integrate 64 ms windows at ~15 Hz instead of 16 ms at 60 Hz — + over 400 main-loop wakeups a second across seven meters became ~100, + with no transient a peak meter would show lost. Steady-state CPU with + two devices and seven meters dropped from ~7% of a core to under 1%. + +### Fixed +- The mix meters actually meter the mixes: a record stream targeting a + sink is silently linked to the default *source* by the session manager, + so every mix bar was showing the default microphone. The meter streams + now set `stream.capture.sink`, landing on each mix's own monitor. +- The window no longer opens cramped on first run: with no saved geometry + it opened at the 820×480 minimum; the default is now 1280×720, and the + matrix gained the bottom margin its other three sides already had. +- Unplugging a Wave while the app runs no longer crashes it: disconnect + could slot between two transfers of one poll and hand libusb a NULL + handle — a segfault, since libusb does not check. The transfer path now + fails cleanly as "device disconnected", poll ticks no longer stack + workers against a dying device, overlapping reconnects cannot stack or + leak USB handles, and closing a handle waits for any in-flight + transfer. The dead unit is dropped, a remaining device takes over the + sidebar, and a replug is reopened automatically within seconds + (verified against a bouncing cable). +- The capture-fix daemon now pins the Wave XLR MK.2 / XLR Dock: its node + name ("Elgato_XLR_Dock_…") never matched the old "Elgato_Wave_" stem, + so the Dock silently ran with no keepalive at all. +- The udev rules and the installed-check both derive from the device + profile list, so a supported device can no longer be missing from either + (an MK.2/Dock-only machine re-ran first-run setup forever). +- The generated app-drawer entry and the packaged one agree on name, + tagline, icon and categories; the icon falls back to a stock one on a + checkout with no installed icons. + +## [1.1.0] — 2026-08-30 + +The mixing matrix release: OpenWave grows from a device control panel into a +sources × mixes router, and takes the name OpenWave. + +### Added +- **Mixing matrix**: user-defined mixes as columns, sources as rows, + per-cell send and mute, per-source trim; drag to reorder or group. +- **Sources**: app rows matched by name (several names per row), hardware + capture rows, a catch-all row; System/Game/Music/Browser/Voice seeded on + first run; icon picker; bind an app that is not yet running. +- **Per-mix outputs**: every mix picks its own device (or none); output + loopbacks survive the window closing; every mix also published as a + capture source for voice apps, and kept linked across sink recreation. +- **Auto-discovered microphone rows**: every Elgato input gets its own row + named after the device; **microphone groups** with exclusive-live + semantics and one-press hand-over. +- **48 V phantom power** control (the only way to switch it on an XLR Dock). +- **Wave XLR MK.2 / XLR Dock** (`0fd9:00a6`) support, verified on hardware. +- **Remote control**: seven `org.gtk.Actions` on the session bus — levels, + mutes, group switching, and a JSON snapshot — the surface + [openwave-streamdeck](https://github.com/NyleGarcia/openwave-streamdeck) + builds on. +- **Per-source level meters**, empty-mix indicator, muted-row marking. +- Gain shown in dB; gain lock; hardware tracks a slider drag live. +- Mix master volumes remembered and restored across reboots. +- Stalled-capture recovery: a replugged Wave that enumerates but delivers no + frames is detected and reopened. +- Hotplug: reconnect to a Wave that appears after launch. +- App drawer entry, start-at-login and start-in-tray switches; own tray + icons with a live/muted/attention state. +- ALSA controls discovered by name instead of hardcoded numids; ALSA card + matched by USB id so two Elgato devices are told apart. +- Unit suite (19 files) + CI; mixer reconcile paths tested against a fake + PipeWire; suite is sandboxed so it can never touch real user config. +- Tag-driven releases: `.deb`, source tarball and checksums per tag. + +### Fixed +- Intake sinks can no longer win the default-sink election. +- A hidden window no longer overwrites the remembered geometry. +- Source-row sliders actually attenuate (loopback volume, not sink volume). +- An application's audio is moved into its source rather than copied, so a + fader at zero is actually silent. +- Icon theme and install prefix that are not the default both survive. + +## [1.0.0] — 2026-05-25 + +### Added +- First cut of the mix infrastructure: Personal / Chat / Record mix sinks, + per-cell mixing via `pw-loopback`, user-defined app sources, mix matrix UI. +- Device pane moved into a collapsible sidebar. +- Live source level meters; full −128 dB headphone range. +- runit support alongside systemd; WirePlumber suspend-disable rule; + byte-flow watchdog for a wedged keepalive. +- Multi-distro `install.sh` and Makefile. + +## [0.1.5] — 2026-04-14 + +### Fixed +- `--hide` keeps the app alive when the tray is registered. + +## [0.1.4] — 2026-04-14 + +### Fixed +- `--hide` registered as a proper GApplication option. + +## [0.1.3] — 2026-04-14 + +### Fixed +- PKGBUILD referenced deleted docs. + +## [0.1.2] — 2026-04-14 + +### Fixed +- udev detection for the old rule filename. + +## [0.1.1] — 2026-04-14 + +Initial release: Wave XLR control (gain, mute, headphone volume, low +impedance), capture-fix daemon with uninstall, first-run setup, PKGBUILD and +release workflow. + +[Unreleased]: https://github.com/NyleGarcia/openwave/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/NyleGarcia/openwave/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/NyleGarcia/openwave/compare/v0.1.5...v1.0.0 +[0.1.5]: https://github.com/NyleGarcia/openwave/compare/v0.1.4...v0.1.5 +[0.1.4]: https://github.com/NyleGarcia/openwave/compare/v0.1.3...v0.1.4 +[0.1.3]: https://github.com/NyleGarcia/openwave/compare/v0.1.2...v0.1.3 +[0.1.2]: https://github.com/NyleGarcia/openwave/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/NyleGarcia/openwave/releases/tag/v0.1.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7b07de3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing to OpenWave + +## Running from a checkout + +No build step, no install: + +```bash +git clone https://github.com/NyleGarcia/openwave.git +cd openwave +python3 -m wavexlr +``` + +Dependencies: Python 3.10+, PyGObject (GTK4 + libadwaita), libusb 1.0, +PipeWire. Optional: `python-xlib` for friendlier app names in the Add Source +picker. Nothing is pip-installed; every distro ships these as system +packages (see `install.sh` for the per-distro lists). + +No Elgato hardware is required for most work: the app runs fully without a +device (`tests/test_no_elgato.py` pins that), and the whole mixing matrix is +plain PipeWire. + +## Tests + +```bash +python3 -m unittest discover -s tests -t . +``` + +Plain `unittest`, no pytest, no test dependencies, no display, no audio +server, no hardware. CI runs the suite on Python 3.10 and 3.13 plus a +`compileall` pass (`.github/workflows/tests.yml`). + +Two things about the suite worth knowing before writing a test: + +- **`tests/__init__.py` redirects every config path into a throwaway + directory at package import.** A test once built a bare mixer without + `temp_config()` and wiped the user's real `~/.config/openwave/mixes.json` + — replaced their whole matrix with test fixtures, at random, whenever the + suite ran. `temp_config()` (from `tests/support.py`) is still the right + tool inside a test; the package-level redirect is the seatbelt for the + test that forgets it. Do not remove it. +- **The mixer is tested against a fake PipeWire.** `Mixer(pw=FakePipeWire())` + (also from `tests/support.py`) turns the reconcile and spawn paths — where + the worst regressions have lived: double-routed audio, loopbacks against + dead links, faders driving nothing — into call-sequence assertions: + configure what the fake graph holds, run one reconcile, read back what the + mixer decided to do. New mixer behaviour should come with a reconcile test; + `tests/test_mixer_reconcile.py` has the patterns. + +The GUI, the USB protocol and the live routing are deliberately not +unit-tested; they are verified against real hardware. The fastest hardware +check is: + +```bash +python3 -m wavexlr.probe dump # quit OpenWave first, tray icon included +``` + +## Working on device support + +Per-model protocol constants live in `wavexlr/profiles.py`; the transport is +`wavexlr/device.py`. `docs/protocol.md` documents the register maps and +`docs/hardware-support.md` the per-device status. Mapping a new field is a +`probe watch` session: move one physical control, read the per-offset diff. + +## Commit messages + +Prose subjects that say what changed and why — not Conventional Commits. +This is deliberate: the release flow versions from tags, not from commit +prefixes (see the header comment in `.github/workflows/release.yml`), so +subjects are written for humans reading `git log`. + +## Cutting a release + +The version is the tag: + +```bash +git tag v1.2.3 && git push v1.2.3 +``` + +`release.yml` gates on the test suite, then builds a source tarball, a +`.deb` and checksums, publishes a GitHub Release with generated notes, +points the PKGBUILD at the released tarball (pkgver + sha256, committed +back to the default branch), and publishes to AUR when the +`AUR_SSH_PRIVATE_KEY` secret is present. It is the only workflow that +creates Releases. A `workflow_dispatch` run of the same workflow exercises +the artifact steps without spending a version number (uploads workflow +artifacts, publishes no Release, touches no PKGBUILD). + +Update `CHANGELOG.md` before tagging. + +## AI assistance + +AI-assisted contributions are fine (parts of this project were built that +way — see the README's AI disclosure) with one hard rule: hardware claims +must be verified on real hardware. A protocol offset, a support statement or +a hardware-support table row needs a `probe` session behind it, not a +model's inference. + +## Documentation + +User-facing behaviour changes belong in `README.md`; routing-model changes +in `docs/ARCHITECTURE.md`; protocol findings in `docs/protocol.md` and +`docs/hardware-support.md`. The README's feature list and repository-layout +tree are checked against the code by reviewers — keep them true. diff --git a/Makefile b/Makefile index d212c60..b6d9809 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ install: check-prefix install -Dm644 icons/openwave-symbolic.svg $(ICONDIR)/symbolic/apps/openwave-symbolic.svg install -Dm644 icons/openwave-muted-symbolic.svg $(ICONDIR)/symbolic/apps/openwave-muted-symbolic.svg install -Dm644 icons/openwave-attention-symbolic.svg $(ICONDIR)/symbolic/apps/openwave-attention-symbolic.svg + install -Dm644 com.github.openwave.metainfo.xml $(DATADIR)/metainfo/com.github.openwave.metainfo.xml install -Dm644 README.md $(DOCDIR)/README.md install -Dm644 LICENSE $(LICENSEDIR)/LICENSE # hicolor keeps a cache, and GTK trusts it over the directory when it is @@ -46,6 +47,7 @@ uninstall: rm -f $(BINDIR)/openwave rm -f $(BINDIR)/openwave-daemon rm -f $(DESKTOPDIR)/openwave.desktop + rm -f $(DATADIR)/metainfo/com.github.openwave.metainfo.xml rm -rf $(APPDIR) rm -rf $(DOCDIR) rm -f $(ICONDIR)/scalable/apps/openwave.svg diff --git a/README.md b/README.md index 55f908e..f51cae4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # OpenWave +[![Tests](https://github.com/NyleGarcia/openwave/actions/workflows/tests.yml/badge.svg)](https://github.com/NyleGarcia/openwave/actions/workflows/tests.yml) +[![Release](https://github.com/NyleGarcia/openwave/actions/workflows/release.yml/badge.svg)](https://github.com/NyleGarcia/openwave/actions/workflows/release.yml) +[![AUR version](https://img.shields.io/aur/version/openwave)](https://aur.archlinux.org/packages/openwave) + **The audio mixing matrix for Linux.** Per-app mixes with per-mix outputs, plus native control of **Elgato Wave** hardware — the **Wave XLR** interface (original and MK.2/XLR Dock) and the **Wave:3** microphone. A reverse-engineered replacement for Elgato Wave Link, built with GTK4 + Adwaita. ![OpenWave](docs/screenshot.png) @@ -12,11 +16,16 @@ for voice apps, and a Record Mix routed nowhere but still recordable. ## Supported devices -| Device | USB ID | Controls | -|---|---|---| -| Wave XLR | `0fd9:007d` | Gain, mute, headphone volume, low impedance mode, **48 V phantom power** | -| Wave XLR MK.2 | `0fd9:00a6` | as the Wave XLR — it enumerates as "Elgato XLR Dock" and speaks the same vendor protocol | -| Wave:3 | `0fd9:0070` | Gain, mute, headphone volume, monitor mix | +| Device | USB ID | Status | Controls | +|---|---|---|---| +| Wave XLR | `0fd9:007d` | 🟢 supported | Gain, mute, headphone volume, low impedance mode, **48 V phantom power**, knob-mode readout | +| Wave XLR MK.2 / XLR Dock | `0fd9:00a6` | 🟢 supported | as the Wave XLR — it enumerates as "Elgato XLR Dock" and speaks the same vendor protocol, verified on hardware | +| Wave:3 | `0fd9:0070` | 🟢 supported | Gain, mute, headphone volume, monitor mix, 3-way dial mode | +| Wave XLR MK.2 (`00b6` revision) | `0fd9:00b6` | ⚪ not yet | a different MK.2 revision — UAC2, different control scheme, decoded by [CryoByte33/openwave](https://github.com/CryoByte33/openwave); deferred for lack of hardware | + +Details, per-control status and protocol notes: +[docs/hardware-support.md](docs/hardware-support.md). Have an untested device? +See [Reporting problems](#reporting-problems). 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 @@ -25,21 +34,32 @@ all, so on that hardware the app is the only way to switch it. ## Features -- **Mixing matrix** — user-defined mixes as columns, sources as rows. Each cell - is how much of that source the mix receives; each source row carries a trim - applying everywhere. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). +### Mixing matrix + +- **Sources × mixes grid** — user-defined mixes as columns, sources as rows. + Each cell is how much of that source the mix receives; each source row + carries a trim applying everywhere, with a per-cell mute on every send. + See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - **Sources** — an application matched by name (several names per row, so one fader can cover every game or two music players), or a hardware capture device such as a headset microphone. One row may be the catch-all for - anything unmatched. -- **Levels survive a reboot** — a mix master is a plain PipeWire sink volume, - and the mix sinks are `context.objects` in PipeWire's own configuration, so - the daemon recreates them at unity on every start and WirePlumber does not - restore them: they are neither streams nor devices it manages. OpenWave - remembers them itself and puts them back. -- **Per-mix output** — every mix chooses its own output device, or none at all - for a mix that exists only to be captured. A mix keeps playing when the - window is closed. + anything unmatched. Every source and mix gets a pickable icon. +- **Live level meters** — every source row meters its own audio, every mix + header meters what the mix carries, and a row waiting for its application + says so instead of sitting silent. +- **Mix master sliders** — each column header carries its mix's master + volume. The slider follows outside movers too: whoever turns a master — + pavucontrol, a media key, a scene — the header shows it within seconds. +- **Per-mix output** — every mix chooses its own output device from a menu: + *Automatic* (labelled with the device it resolved to), any live sink, or + *Not monitored* for a mix that exists only to be captured. A remembered + device that is currently absent stays selectable, marked "(unavailable)". + A mix keeps playing when the window is closed. +- **Every mix is a microphone** — each mix is also published as a capture + source, so a voice app or OBS can select it as an input. +- **Levels survive a reboot** — PipeWire recreates the mix sinks at unity on + every start and WirePlumber does not restore them. OpenWave remembers the + masters itself and puts them back. See [Mix levels and reboots](#mix-levels-and-reboots). - **Microphone rows appear by themselves** — every Elgato capture input gets a row named after its device ("XLR Dock", "Wave XLR"), so two interfaces connected at once are told apart instead of contending for a single @@ -51,148 +71,143 @@ all, so on that hardware the app is the only way to switch it. speaker's microphone stays open. Two mics on one person and one on another is two groups. - **Sensible defaults** — System, Game, Music, Browser and Voice rows ship - pre-matched to the usual applications, with System as the catch-all. -- **Remote control** — mixes, source trims and microphone groups are drivable - from outside the window over the session bus. See + pre-matched to the usual applications, with System as the catch-all. An + empty mix says it carries nothing rather than looking broken. +- **Scenes** — every trim, send, mute, output, master and device setting + saved under a name and recalled as one gesture, from the header-bar menu + or the session bus. A scene sets levels on the matrix that exists — it + never creates or deletes rows or columns, and one that names things since + removed applies what still matches. The gain lock wins over a scene's + gain. +- **Remote control** — mixes, source trims, microphone groups and scenes + are drivable from outside the window over the session bus. See [Remote control](#remote-control). -- **Microphone controls** — Gain, mute (syncs with hardware button), 48 V - phantom power -- **Headphone controls** — Volume (syncs with hardware knob), low impedance mode -- **Hardware sync** — 10 Hz polling keeps the app in sync with physical controls -- **System integration** — Mute and HP volume sync bidirectionally with PipeWire/ALSA + +### Device control + +- **Microphone** — gain in dB with a **gain lock** (lock the slider so a stray + drag cannot blow out a dialled-in level), mute synced with the hardware + button, 48 V phantom power. +- **Headphones** — volume synced with the hardware knob, low impedance mode, + and on a Wave:3 a **monitor mix** slider (mic/PC crossfade). +- **Knob readout** — shows what the physical dial currently controls. +- **Device info** — firmware version, protocol API version and serial number, + read from the device itself. +- **Hardware sync** — 10 Hz polling keeps the app in sync with physical + controls; slider drags are throttled so the hardware tracks the drag + instead of hearing about it after. +- **System integration** — mute and volumes sync bidirectionally with + PipeWire/ALSA, with ALSA controls discovered by name so a firmware revision + that renumbers them cannot break it. +- **Hotplug** — a Wave plugged in after launch is picked up automatically. +- **Multiple devices** — every connected Wave is opened, polled and + ALSA-synced at once, two of the same model included (told apart by USB + bus address and serial). A Device dropdown appears in the sidebar when + more than one is connected; the capture-fix daemon pins each device's + stream; scenes record hardware per serial; and the tray reports muted if + any device's hardware mute is down. + +### Reliability + - **Audio capture fix** — a background daemon (systemd or runit) prevents the - firmware race where the microphone goes silent, and OpenWave itself - **reopens a capture device that has stalled**: replugged while the system is - running, a Wave comes back reporting itself unmuted at full gain with - phantom on, and delivers no audio frames at all. Every layer says it is - healthy, so nothing notices. See [Stalled capture](#stalled-capture). -- **System tray** — Runs in background with tray icon, mute from tray menu -- **First-run setup** — Configures udev permissions and audio service automatically + firmware race where the microphone goes silent, with a byte-flow watchdog + for a keepalive that wedged without dying. The sidebar warns when the + service is missing and can install — or uninstall — it in place. +- **Stalled capture recovery** — a Wave replugged while the system runs can + come back claiming to be healthy while delivering no frames; OpenWave + detects that and reopens it. See [Stalled capture](#stalled-capture). +- **Corrupt config survival** — an unreadable mix store is preserved as + `mixdefs.json.corrupt` and replaced with the defaults, so a bad write never + leaves the app with no mixes at all. +- **Icon-theme resilience** — icon names Breeze lacks are substituted at draw + time, so the UI survives a non-default theme without rewriting your config. + +### Desktop integration + +- **System tray** — StatusNotifier icon with mute from the menu; the tooltip + distinguishes hardware mute, matrix mute, and both. On a desktop with no + tray host (stock GNOME), OpenWave shows its window instead of hiding into + nothing. +- **App drawer, start at login, start in the tray** — all handled by switches + in the app; no files to copy. See + [App drawer, starting at login, starting in the tray](#app-drawer-starting-at-login-starting-in-the-tray). +- **Responsive layout** — the device pane is a collapsible sidebar; the window + remembers its geometry (and a hidden window cannot clobber it). +- **First-run setup** — configures udev permissions and the audio service + automatically, via polkit. + +## How OpenWave compares + +Two other projects live in the same space: [openxlr](https://github.com/emaspa/openxlr), +a C#/.NET control suite for Elgato XLR interfaces on Linux, and Elgato's own +**Wave Link** on Windows/macOS. Roughly: openxlr covers more XLR hardware +variants (the Wave XLR Pro, the `00b6` MK.2) and adds host-side DSP and an +OpenDeck plugin; Wave Link has the deepest effects stack and no Linux +version; OpenWave covers the Wave:3, models mixing as one sources × mixes +matrix with scenes and microphone groups, and runs on plain Python + +PyGObject with no runtime to install. Pick openxlr for its hardware and +DSP; pick OpenWave for the matrix. ## How it works Wave devices use USB Class control transfers on endpoint 0 for device configuration. On Linux, `snd-usb-audio` normally blocks these transfers because `wIndex=0x3300` routes through interface 0 (owned by the audio driver). OpenWave uses `wIndex=0x3303` instead — the firmware only checks the `0x33` prefix, while the kernel sees interface 3 (unclaimed) and lets the transfer through. No driver detach needed, audio is never interrupted. -Both devices speak the same vendor protocol (`bRequest` 0x85 read / 0x05 write) but with different config layouts: the Wave XLR uses a 34-byte block (gain uint16 @0, mute @4, HP volume int16 Q8.8 @9, knob mode @14, low-Z @33), the Wave:3 a 16-byte block (gain uint16 Q8.8 dB @0, mute @4, HP volume int16 Q8.8 @7, monitor mix uint16 Q8.8 percent @10, dial mode @12 — 1=gain, 2=headphones, 3=mix). Per-model constants live in `wavexlr/profiles.py`; `python3 -m wavexlr.probe` (`dump` / `watch` / `poke`) verifies a device against its profile and helps map new fields. The device services vendor transfers from only one process at a time, so quit OpenWave before probing. - -## Mix levels and reboots - -A mix master is a plain PipeWire sink volume, and the mix sinks are -`context.objects` in PipeWire's configuration — recreated by the daemon on -every start, at unity, with no memory. WirePlumber does not restore them -either, because they are neither streams nor devices it manages. Left alone, -every mix master silently resets to 100% at each boot, including anything set -from a control surface. - -OpenWave remembers them in `mixes.json` under `volumes` and applies them once -the sinks exist. It records what the master is actually set to rather than -only what its own window did, because anything may move it — a Stream Deck, -`pavucontrol`, a media key — and whoever moved it, that is the value that -should come back. - -Observation is gated on the restore having happened, and that gate is the -point rather than an optimisation. At boot the sinks exist at unity before -OpenWave does; an observation landing first would persist that unity and -destroy the value it exists to protect — silently, exactly once per boot, -which is indistinguishable from never having saved anything. - -## Stalled capture - -A Wave replugged while the system is running enumerates, gets its ALSA card -and its PipeWire node, reports itself unmuted at full gain with phantom power -on — and produces nothing. Not quiet audio: no frames. - -The distinction that makes it detectable is **silence versus no data**. A live -analogue input always delivers a noise floor; a stalled one delivers nothing, -so a meter reading it blocks forever on its first read. That is the signal -OpenWave watches, and it is why a level threshold would be the wrong test — a -muted microphone in a quiet room is legitimately near zero and must not be -"recovered". +All supported devices speak the same vendor protocol (`bRequest` 0x85 read / 0x05 write) but with different config layouts; per-model constants live in `wavexlr/profiles.py`, and the full register maps are documented in [docs/protocol.md](docs/protocol.md). `python3 -m wavexlr.probe` (`dump` / `watch` / `poke`) verifies a device against its profile and helps map new fields. The device services vendor transfers from only one process at a time, so quit OpenWave before probing. -The remedy is to make ALSA close and reopen the device, which cycling the -card's profile through `off` and back does. Restarting the capture keepalive -does not: it exists to *prevent* the race and cannot clear one that has -already happened. +The mixing half is a router built from ordinary PipeWire objects — null sinks +and `pw-loopback` children, no custom audio code. +[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) explains the routing model: why an +application's audio is moved rather than copied, how trim and send compose, and +why every stream gets exactly one owner. -Three things it deliberately will not do. It will not act on a device that is -simply absent — unplugged is not broken, and cycling a card for a device -someone has just removed fights the person who removed it. It will not act on -silence reported by a dead meter subprocess, whose silence says something -about `pw-cat` and nothing about the hardware. And it gives up after two -attempts, because cycling a card is disruptive and a device that is genuinely -broken should be left alone to be noticed rather than reopened every minute -forever. Unplugging resets that budget, since replugging is how the stall -arises in the first place. +## Install -## Remote control +Tagged releases on the [Releases page](../../releases) carry ready-made +objects: a `.deb` for Debian/Ubuntu (`sudo apt install ./openwave_*.deb`), +a source tarball, and checksums. -OpenWave exports a small set of actions on the session bus, so a control -surface can drive the parts of it that PipeWire alone cannot reach — the -window owns the mixer state, and the GUI holds the only USB handle the -firmware will serve. +### One-liner -There is no protocol of its own: `GApplication` already exports -`org.gtk.Actions` on `com.github.openwave`. +Detects Arch, Debian/Ubuntu, Fedora, openSUSE, or Void; installs deps and OpenWave: -```console -$ gdbus call --session --dest com.github.openwave \ - --object-path /com/github/openwave --method org.gtk.Actions.List -(['switch-group', 'set-source-level', 'toggle-source-mute', - 'set-cell-level', 'toggle-cell-mute', 'source-groups', 'snapshot'],) +```bash +curl -fsSL https://raw.githubusercontent.com/NyleGarcia/openwave/main/install.sh | sh ``` -| Action | Parameter | Does | -|---|---|---| -| `switch-group` | `s` group name | Hands a microphone group to its next member | -| `set-source-level` | `(sd)` id, 0–1 | Sets a source's trim | -| `toggle-source-mute` | `s` id | Flips a source's mute, group rules included | -| `set-cell-level` | `(ssd)` source, mix, 0–1 | Sets one send — how much of a source a single mix receives | -| `toggle-cell-mute` | `(ss)` source, mix | Flips one cell's mute | -| `source-groups` | — | State: group names worth switching between | -| `snapshot` | — | State: every source, mix and cell, as JSON | - -The two read-only actions publish their answer as action *state* rather than -returning it: `Activate` has no reply, but `Describe` reads state and `Changed` -fires when it moves, so a reader can both poll and subscribe. Activate first to -refresh, then describe. - -`snapshot` is one action rather than one per field because a remote control -draws all of it on a single button, and reading it piecemeal would let the -parts disagree mid-read. It reports **every** cell, including the ones at zero: -a caller cannot otherwise tell a send that is down from one that does not -exist. +### Arch Linux -Everything goes through the window rather than the config files. `Mixer` holds -the same dict the window holds and rewrites `sources.json` whole on every save, -so a caller writing that file directly is overwritten the next time a fader -moves — and a cell written straight to `mixes.json` is undone even faster, -because `send × trim` is re-applied on every reconcile. +```bash +yay -S openwave # AUR +``` -[**openwave-streamdeck**](https://github.com/NyleGarcia/openwave-streamdeck) is -a Stream Deck plugin built on this. +### From a checkout -## Install +```bash +git clone https://github.com/NyleGarcia/openwave.git +cd openwave +./install.sh # default PREFIX=/usr/local +PREFIX=/usr ./install.sh # for packaging-style layout +``` -Tagged releases on the [Releases page](../../releases) carry ready-made -objects: a `.deb` for Debian/Ubuntu (`sudo apt install ./openwave_*.deb`), -a source tarball, and checksums. +### Nix -One-liner — detects Arch, Debian/Ubuntu, Fedora, openSUSE, or Void; installs deps and OpenWave: +The repo is a flake exposing `packages..openwave` (also `default`) +for `x86_64-linux` and `aarch64-linux`: ```bash -curl -fsSL https://raw.githubusercontent.com/rikkichy/openwave/main/install.sh | sh +nix run github:NyleGarcia/openwave +nix profile install github:NyleGarcia/openwave ``` -Or from a checkout: +On NixOS, the package ships the udev rules the first-run setup would +otherwise write (pkexec cannot write to the read-only store), so consume +them declaratively: -```bash -git clone https://github.com/rikkichy/openwave.git -cd openwave -./install.sh # default PREFIX=/usr/local -PREFIX=/usr ./install.sh # for packaging-style layout +```nix +services.udev.packages = [ openwave ]; ``` -Uninstall: +### Uninstall ```bash sudo make -C /path/to/openwave uninstall PREFIX=/usr/local @@ -258,7 +273,142 @@ that will not happen. python3 -m wavexlr --hide ``` -## Architecture +## Remote control + +OpenWave exports a small set of actions on the session bus, so a control +surface can drive the parts of it that PipeWire alone cannot reach — the +window owns the mixer state, and the GUI holds the only USB handle the +firmware will serve. + +There is no protocol of its own: `GApplication` already exports +`org.gtk.Actions` on `com.github.openwave`. + +```console +$ gdbus call --session --dest com.github.openwave \ + --object-path /com/github/openwave --method org.gtk.Actions.List +(['switch-group', 'set-source-level', 'toggle-source-mute', + 'set-cell-level', 'toggle-cell-mute', 'source-groups', 'snapshot', + 'apply-scene', 'save-scene', 'delete-scene', 'scenes'],) +``` + +| Action | Parameter | Does | +|---|---|---| +| `switch-group` | `s` group name | Hands a microphone group to its next member | +| `set-source-level` | `(sd)` id, 0–1 | Sets a source's trim | +| `toggle-source-mute` | `s` id | Flips a source's mute, group rules included | +| `set-cell-level` | `(ssd)` source, mix, 0–1 | Sets one send — how much of a source a single mix receives | +| `toggle-cell-mute` | `(ss)` source, mix | Flips one cell's mute | +| `apply-scene` | `s` scene id | Recalls a scene; entries naming things that are gone are skipped | +| `save-scene` | `s` name | Captures the current levels under that name | +| `delete-scene` | `s` scene id | Removes a scene | +| `source-groups` | — | State: group names worth switching between | +| `scenes` | — | State: `{scene id: name}` as JSON | +| `snapshot` | — | State: every source, mix and cell, as JSON | + +The two read-only actions publish their answer as action *state* rather than +returning it: `Activate` has no reply, but `Describe` reads state and `Changed` +fires when it moves, so a reader can both poll and subscribe. Activate first to +refresh, then describe. + +`snapshot` is one action rather than one per field because a remote control +draws all of it on a single button, and reading it piecemeal would let the +parts disagree mid-read. It reports **every** cell, including the ones at zero: +a caller cannot otherwise tell a send that is down from one that does not +exist. + +Everything goes through the window rather than the config files. `Mixer` holds +the same dict the window holds and rewrites `sources.json` whole on every save, +so a caller writing that file directly is overwritten the next time a fader +moves — and a cell written straight to `mixes.json` is undone even faster, +because `send × trim` is re-applied on every reconcile. + +[**openwave-streamdeck**](https://github.com/NyleGarcia/openwave-streamdeck) is +a Stream Deck plugin built on this. + +## Mix levels and reboots + +A mix master is a plain PipeWire sink volume, and the mix sinks are +`context.objects` in PipeWire's configuration — recreated by the daemon on +every start, at unity, with no memory. WirePlumber does not restore them +either, because they are neither streams nor devices it manages. Left alone, +every mix master silently resets to 100% at each boot, including anything set +from a control surface. + +OpenWave remembers them in `mixes.json` under `volumes` and applies them once +the sinks exist. It records what the master is actually set to rather than +only what its own window did, because anything may move it — a Stream Deck, +`pavucontrol`, a media key — and whoever moved it, that is the value that +should come back. + +Observation is gated on the restore having happened, and that gate is the +point rather than an optimisation. At boot the sinks exist at unity before +OpenWave does; an observation landing first would persist that unity and +destroy the value it exists to protect — silently, exactly once per boot, +which is indistinguishable from never having saved anything. + +## Stalled capture + +A Wave replugged while the system is running enumerates, gets its ALSA card +and its PipeWire node, reports itself unmuted at full gain with phantom power +on — and produces nothing. Not quiet audio: no frames. + +The distinction that makes it detectable is **silence versus no data**. A live +analogue input always delivers a noise floor; a stalled one delivers nothing, +so a meter reading it blocks forever on its first read. That is the signal +OpenWave watches, and it is why a level threshold would be the wrong test — a +muted microphone in a quiet room is legitimately near zero and must not be +"recovered". + +The remedy is to make ALSA close and reopen the device, which cycling the +card's profile through `off` and back does. Restarting the capture keepalive +does not: it exists to *prevent* the race and cannot clear one that has +already happened. + +Three things it deliberately will not do. It will not act on a device that is +simply absent — unplugged is not broken, and cycling a card for a device +someone has just removed fights the person who removed it. It will not act on +silence reported by a dead meter subprocess, whose silence says something +about `pw-cat` and nothing about the hardware. And it gives up after two +attempts, because cycling a card is disruptive and a device that is genuinely +broken should be left alone to be noticed rather than reopened every minute +forever. Unplugging resets that budget, since replugging is how the stall +arises in the first place. + +## Configuration files + +| File | Holds | +|---|---| +| `~/.config/openwave/mixdefs.json` | mix identity: name, icon, sink, description | +| `~/.config/openwave/sources.json` | source identity, bindings, trim | +| `~/.config/openwave/mixes.json` | per-cell levels, per-mix outputs, mix master volumes | +| `~/.config/openwave/ui-state.json` | window geometry, gain lock | +| `~/.config/pipewire/pipewire.conf.d/52-openwave-mixes.conf` | generated: one null sink per mix | +| `~/.config/wireplumber/wireplumber.conf.d/51-openwave-wave-xlr.conf` | generated: keeps the Wave from being suspended | + +These are OpenWave's own state, not an interface: values poked into them from +outside are overwritten on the next save or reconcile. Use +[Remote control](#remote-control) instead. + +## Reporting problems + +Open an issue on the [issue tracker](../../issues) and attach a diagnostics +bundle: **Export diagnostics** in the sidebar, or + +```bash +python3 -m wavexlr.diag +``` + +The bundle carries versions, device state, service and PipeWire status — +and no config contents or app names unless you pass `--full`. Prefer the +in-app button when OpenWave is running: the firmware serves vendor +transfers to one process at a time, so the CLI cannot read a device the +app holds open. For deeper protocol digging there is +`python3 -m wavexlr.probe dump` (quit OpenWave first, tray icon included). If you have a Wave device that is not in +the [supported table](#supported-devices) — the `0fd9:00b6` MK.2 revision +especially — a `probe dump`, plus `probe watch` output while you move each +physical control, is exactly what adding support needs. + +## Repository layout ``` wavexlr/ @@ -277,25 +427,31 @@ wavexlr/ mixdialog.py — Create/rename a mix sourcedialog.py — Add or edit a source meter.py — Level metering via pw-cat + recovery.py — Stalled-capture detection and card-profile cycling + scheduler.py — Slider-write throttling seam + icons.py — Draw-time icon substitution for themes missing names + desktop.py — App drawer and autostart entries + wmnames.py — Friendly app names via X11/XWayland (optional) service.py — systemd/runit unit management paths.py — Install-prefix resolution +docs/ — architecture, hardware support, protocol, comparison +tests/ — unit suite (no GTK, no PipeWire, no hardware needed) ``` -[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) explains the routing model: why an -application's audio is moved rather than copied, how trim and send compose, and -why every stream gets exactly one owner. - ## Development -Run from a checkout without installing: +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full picture. The short +version — run from a checkout without installing: ```bash python3 -m wavexlr ``` The tests cover the backend — matching, the stores, state migration, the -generated config and the device scaling. They import neither GTK nor a running -PipeWire, so they need no display, no audio server and no hardware: +generated config, the device scaling, the mixer's reconcile decisions +(against a fake PipeWire), stall recovery, the tray, the desktop entries and +the throttler. They import neither GTK nor a running PipeWire, so they need +no display, no audio server and no hardware: ```bash python3 -m unittest discover -s tests -t . @@ -310,13 +466,28 @@ first, since the firmware serves one process at a time. USB protocol reverse-engineered from the macOS Wave Link application using Frida. Inspired by [GoXLR-on-Linux/goxlr-utility](https://github.com/GoXLR-on-Linux/goxlr-utility). +The shape of this documentation — the hardware-support matrix, the protocol +reference, the per-distro install sections, the AI disclosure — is modeled on +[emaspa/openxlr](https://github.com/emaspa/openxlr), the sibling project for +Elgato's XLR interfaces, whose README sets the bar for this niche. + Several ideas and two modules are ported from [CryoByte33/openwave](https://github.com/CryoByte33/openwave), a sibling fork: the friendly-app-name resolution (`wmnames.py` and the generic-name rules), the slider `Throttler` and its scheduler seam, ALSA control discovery by name suffix, the hotplug reconnect loop, and the duplicate-source picker -guard. cryobyte33's fork also decoded the Wave XLR MK.2 (`0fd9:00b6`) vendor -protocol, which this tree defers only for lack of that hardware. +guard. cryobyte33's fork also decoded the `0fd9:00b6` Wave XLR MK.2 revision — +a UAC2 device with a different control scheme from the `0fd9:00a6` XLR Dock +this tree supports — which this tree defers only for lack of that hardware. + +## AI disclosure + +Parts of this project — code and documentation — were developed with AI +assistance. Everything that touches hardware is verified by a human against +real devices: the protocol findings in [docs/protocol.md](docs/protocol.md) +come from `probe` sessions on live hardware, not from a model's guess, and +the support claims in [docs/hardware-support.md](docs/hardware-support.md) +state explicitly what has been verified on hardware and what has not. ## License diff --git a/com.github.openwave.metainfo.xml b/com.github.openwave.metainfo.xml new file mode 100644 index 0000000..ef3147a --- /dev/null +++ b/com.github.openwave.metainfo.xml @@ -0,0 +1,55 @@ + + + com.github.openwave + CC0-1.0 + MIT + OpenWave + The audio mixing matrix for Linux + +

+ Per-app mixes with per-mix outputs, plus native control of Elgato Wave + hardware — the Wave XLR interface (original and MK.2/XLR Dock) and the + Wave:3 microphone. A reverse-engineered replacement for Elgato Wave + Link, built with GTK4 and libadwaita on PipeWire. +

+

+ Sources are rows, mixes are columns, and each cell is how much of that + source the mix receives. Applications are matched by name, hardware + microphones get their own rows automatically, microphone groups keep + one mic live at a time, every mix can feed its own output device or be + captured as a virtual microphone, and levels survive a reboot. +

+
+ openwave.desktop + https://github.com/NyleGarcia/openwave + https://github.com/NyleGarcia/openwave/issues + + + The mixing matrix with grouped microphones and three mixes + https://raw.githubusercontent.com/NyleGarcia/openwave/v1.1.0/docs/screenshot.png + + + + AudioVideo + Audio + Mixer + + + OpenWave contributors + + + pointing + keyboard + + + usb:v0FD9p007D* + usb:v0FD9p00A6* + usb:v0FD9p0070* + + + + + + + +
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 975619c..5b8d7b9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -190,6 +190,21 @@ puts it in the target's group, starting one named after the target if it had none. Dropping near an edge reorders instead. Two gestures, one control, and no way for the two rows to end up holding group names that differ by a typo. +## The PipeWire seam + +`Mixer` never calls PipeWire tools directly: every `pw-cli`, `pw-loopback`, +`pw-dump`, `wpctl` invocation goes through a `pw` adapter object it is +constructed with. In production that adapter runs the real subprocesses; in +tests `Mixer(pw=FakePipeWire())` substitutes an in-memory graph. + +The seam exists because the reconcile and spawn paths are where the worst +regressions have lived — double-routed audio, loopbacks against dead links, +faders driving nothing — and before it, those paths were ~30 scattered +subprocess calls nothing could exercise. With the fake they are +call-sequence assertions: configure what the graph holds, run one reconcile, +read back what the mixer decided to do about it. +`tests/test_mixer_reconcile.py` is built on this. + ## Remote control `GApplication` already exports `org.gtk.Actions` on `com.github.openwave`, so diff --git a/docs/hardware-support.md b/docs/hardware-support.md new file mode 100644 index 0000000..b3d2f7a --- /dev/null +++ b/docs/hardware-support.md @@ -0,0 +1,104 @@ +# Hardware support + +Format modeled on [openxlr's hardware-support page](https://github.com/emaspa/openxlr/blob/main/docs/hardware-support.md). + +Per-device status for everything OpenWave knows about. The USB protocol +itself — transport, register maps, encodings — is in +[protocol.md](protocol.md); per-model constants live in +[`wavexlr/profiles.py`](../wavexlr/profiles.py). + +Legend: 🟢 verified on hardware · 🟡 decoded, needs real-hardware testing · +⚪ not supported / unknown. + +| Device | USB ID | Status | +|---|---|---| +| [Wave XLR](#wave-xlr) | `0fd9:007d` | 🟢 | +| [Wave XLR MK.2 / XLR Dock](#wave-xlr-mk2--xlr-dock) | `0fd9:00a6` | 🟢 | +| [Wave:3](#wave3) | `0fd9:0070` | 🟢 | +| [Wave XLR MK.2 (`00b6` revision)](#wave-xlr-mk2-00b6-revision) | `0fd9:00b6` | ⚪ | + +## Wave XLR + +`0fd9:007d` — the original XLR interface. 34-byte config block. + +| Control | Status | Notes | +|---|---|---| +| Mic gain | 🟢 | uint16 @0, 256 raw/dB, max `0x5000` = 80 dB | +| Mute | 🟢 | byte @4, syncs with the hardware mute pad and ALSA | +| 48 V phantom power | 🟢 | byte @6; found by diffing the block across a toggle, confirmed against the +48V LED | +| Headphone volume | 🟢 | int16 Q8.8 @9, syncs with the knob and ALSA | +| Knob mode readout | 🟢 | byte @14, `0x02` = knob drives headphones | +| Low impedance mode | 🟢 | byte @33 | +| Device info | 🟢 | firmware, API version, serial | +| Meters | 🟢 | 10-byte block, two uint32 levels | +| Monitor mix | ⚪ | no such field on this device | + +## Wave XLR MK.2 / XLR Dock + +`0fd9:00a6` — enumerates as "Elgato XLR Dock" but speaks the original Wave +XLR's vendor protocol byte for byte: a probe dump against hardware decodes +gain @0, mute @4, HP volume @9 and low-Z @33 exactly as the original, and the +serial at offset 27 matches the ALSA card serial. Everything in the Wave XLR +table above applies, verified on a live Dock. + +One difference matters: the Dock has **no front-panel phantom button at +all**, so OpenWave's switch is the only way to toggle 48 V on this hardware. + +Not to be confused with the `0fd9:00b6` revision below, which is a different +device. + +## Wave:3 + +`0fd9:0070` — the USB microphone. 16-byte config block. + +| Control | Status | Notes | +|---|---|---| +| Mic gain | 🟢 | uint16 @0, 256 raw/dB, max `0x2800` = 40 dB; mirrored into ALSA | +| Mute | 🟢 | byte @4, syncs with the capacitive mute and ALSA | +| Headphone volume | 🟢 | int16 Q8.8 @7 | +| Monitor mix | 🟢 | uint16 Q8.8 percent @10, max `0x6400`; the mic/PC crossfade, exposed as a sidebar slider | +| Dial mode readout | 🟢 | byte @12: 1 = gain, 2 = headphones, 3 = mix | +| Device info | 🟢 | firmware, API version, serial | +| Meters | 🟢 | 8-byte block | +| Low impedance / phantom | ⚪ | no XLR input, no such fields | + +## Wave XLR MK.2 (`00b6` revision) + +`0fd9:00b6` — a different MK.2 revision: a USB Audio Class 2 device with a +control scheme unlike the `00a6` Dock's. +[CryoByte33/openwave](https://github.com/CryoByte33/openwave) decoded its +vendor protocol; this tree defers it only for lack of that hardware. + +**Have one?** That is exactly the missing piece. Start with **Export +diagnostics** in the sidebar (or `python3 -m wavexlr.diag`) for the +overview, then quit OpenWave (including the tray icon — the firmware serves +one process at a time) and capture: + +```bash +python3 -m wavexlr.probe dump # config / meter / devinfo blocks +python3 -m wavexlr.probe watch # per-offset diffs while you move each control +``` + +Open an issue with the output and which physical control you moved for each +diff. See [protocol.md](protocol.md) for how the probe maps fields. + +## Implementation notes + +- **`wIndex=0x3303`** — vendor transfers officially route through + `wIndex=0x3300` (interface 0), which `snd-usb-audio` owns and blocks. The + firmware only checks the `0x33` prefix, so OpenWave uses `0x3303`: the + kernel sees unclaimed interface 3 and lets it through. No driver detach, + audio never interrupted. +- **One process at a time** — the firmware services vendor transfers from a + single process; a second reader gets `-EIO`. +- **ALSA controls found by name suffix, not numid** — the numids 4/5/6 hold + on the hardware in hand but are not promised across firmware revisions; + the control names vary only in their product-string prefix, so the suffix + ("Capture Switch", "Capture Volume", "Playback Volume") is the stable + handle. Ported from CryoByte33/openwave, verified on a live `00a6` Dock. +- **ALSA card matched by `usbid`, not name** — every profile's name match + ends in "Elgato", so with two Elgato devices connected, name matching + resolved them all to whichever card came first. `/proc/asound/card*/usbid` + disambiguates by vid:pid, and `usbbus` splits two of the same model. +- **Control ranges read from the driver** — ALSA maxima differ per device + and kernel; they are read from `amixer` and cached rather than assumed. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..961862e --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,108 @@ +# The Elgato Wave vendor protocol + +What OpenWave knows about the USB protocol the Wave devices speak, as +implemented in [`wavexlr/device.py`](../wavexlr/device.py) and parameterised +per model in [`wavexlr/profiles.py`](../wavexlr/profiles.py). Per-device +support status lives in [hardware-support.md](hardware-support.md). + +Provenance: reverse-engineered from the macOS Wave Link application using +Frida, then verified byte-for-byte against live hardware with +`python3 -m wavexlr.probe`. Nothing here is from vendor documentation. + +## Transport + +Everything is USB Class control transfers on endpoint 0: + +| Field | Read | Write | +|---|---|---| +| `bmRequestType` | `0xA1` (class, interface, IN) | `0x21` (class, interface, OUT) | +| `bRequest` | `0x85` | `0x05` | +| `wValue` | selects the block (below) | selects the block | +| `wIndex` | `0x3303` | `0x3303` | + +### Why `wIndex=0x3303` + +Wave Link uses `wIndex=0x3300`, whose low byte routes the transfer through +interface 0 — owned by `snd-usb-audio` on Linux, which blocks it. The +firmware only checks the `0x33` prefix, so OpenWave sends `0x3303`: the +kernel sees interface 3 (unclaimed) and lets it through. No driver detach, +audio never interrupted. + +### One process at a time + +The firmware services vendor transfers from a single process. A second +reader gets `-EIO`; quit OpenWave (tray icon included) before probing. + +### Writes are read-modify-write + +There is no per-field write. OpenWave reads the whole config block, patches +the field, and writes the whole block back. + +## Blocks + +Three `wValue`-selected blocks, same on every model (lengths differ): + +| Block | `wValue` | Wave XLR / Dock | Wave:3 | +|---|---|---|---| +| config | `0x0000` | 34 bytes | 16 bytes | +| meter | `0x0001` | 10 bytes | 8 bytes | +| devinfo | `0x000A` | 51 bytes | 64 bytes | + +The meter block starts with two little-endian uint32 levels (left, right). + +## Config block — Wave XLR and Wave XLR MK.2 / XLR Dock + +`0fd9:007d` and `0fd9:00a6` share this layout byte for byte (the MK.2/Dock +was verified against live hardware). 34 bytes. + +| Offset | Size / type | Field | Encoding | +|---|---|---|---| +| 0 | uint16 LE | Mic gain | 256 raw units per dB; max `0x5000` = 80 dB. Measured against the ALSA `Mic Capture Volume` control at 20/40/60/75 dB: `0x1400`/`0x2800`/`0x3C00`/`0x4B00`, exactly 256.00 raw/dB at every point | +| 4 | byte | Mute | `0x01` muted, `0x00` live | +| 6 | byte | 48 V phantom power | `0x01` on, `0x00` off. Found by watching the block while the dial was held: byte 6 flipped with the 48V LED and nothing else moved | +| 9 | int16 LE | Headphone volume | Q8.8 dB (raw / 256), 0 = unity, negative = attenuation | +| 14 | byte | Knob mode | `0x02` = knob drives headphone volume | +| 33 | byte | Low impedance mode | `0x01` on, `0x00` off | + +Devinfo (51 bytes): API version at bytes 0–1 (`major.minor`), firmware at +6–8 (`x.y.z`), serial as ASCII at 27–46. + +## Config block — Wave:3 + +`0fd9:0070`. 16 bytes. + +| Offset | Size / type | Field | Encoding | +|---|---|---|---| +| 0 | uint16 LE | Mic gain | 256 raw/dB; max `0x2800` = 40 dB | +| 4 | byte | Mute | `0x01` muted | +| 7 | int16 LE | Headphone volume | Q8.8 dB | +| 10 | uint16 LE | Monitor mix | Q8.8 percent, max `0x6400` = 100 — the mic/PC crossfade | +| 12 | byte | Dial mode | `0x01` = gain, `0x02` = headphones, `0x03` = monitor mix | + +Devinfo (64 bytes): API at 0–1, firmware at 21–23, serial at 36–47. + +## Probing a device + +`python3 -m wavexlr.probe` is the tool everything above was verified with: + +```bash +python3 -m wavexlr.probe dump # config/meter/devinfo, hexdumped, + # with expected-vs-actual lengths +python3 -m wavexlr.probe dump --wvalue 0x2 --len 512 # explore an unknown block +python3 -m wavexlr.probe watch # poll config, print per-offset + # diffs while you move controls +python3 -m wavexlr.probe poke --noop # write the block back unchanged + # (proves writes are accepted) +python3 -m wavexlr.probe poke --offset 6 --byte 0x01 # flip one byte (confirms) +``` + +The method that mapped every field above: `watch`, move exactly one physical +control, read which offset moved. Then `poke` the offset and confirm the +hardware reacts. `poke --noop` first — a device that rejects a full-block +write-back is telling you the layout is wrong before you change anything. + +Mapping a new device is: add a `DeviceProfile` to `profiles.py` (copy the +closest existing one), `dump` to check the block lengths, `watch` to map +offsets, `poke` to confirm. See +[hardware-support.md](hardware-support.md#wave-xlr-mk2-00b6-revision) for +the device we are currently looking for. diff --git a/pipewire/52-openwave-mixes.conf b/pipewire/52-openwave-mixes.conf index c0607ce..d94ab74 100644 --- a/pipewire/52-openwave-mixes.conf +++ b/pipewire/52-openwave-mixes.conf @@ -7,8 +7,8 @@ # - openwave_chat_mix : send to voice apps via Monitor of OpenWave Chat Mix # - openwave_record_mix : send to OBS via Monitor of OpenWave Record Mix # -# Per-cell mixing through OpenWave's matrix arrives in v0.3.0; until then -# these sinks behave as ordinary virtual outputs. +# This file is the static fallback: on first-run setup OpenWave generates +# the real one from the user's mix definitions (see wavexlr/setup.py). context.objects = [ { factory = adapter From 10fc4625f31abd593e32d221d1cb63cf60a5a57b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:25 -0500 Subject: [PATCH 67/99] Plan the enhancement sprint the comparison exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-horizon planning tree: the active sprint (hygiene fixes, diagnostics export, scenes, multi-device — implemented over the same day), the DSP chain parked in next/ with its risks named, and an ice box for the hardware-blocked and speculative items. Specs for scenes and diagnostics carry the design decisions so the code does not have to re-argue them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- plans/later/ice-box.md | 20 ++++ plans/next/dsp-chain.md | 37 ++++++ plans/now/todo.md | 192 ++++++++++++++++++++++++++++++ plans/specs/diagnostics-export.md | 53 +++++++++ plans/specs/profiles-scenes.md | 79 ++++++++++++ 5 files changed, 381 insertions(+) create mode 100644 plans/later/ice-box.md create mode 100644 plans/next/dsp-chain.md create mode 100644 plans/now/todo.md create mode 100644 plans/specs/diagnostics-export.md create mode 100644 plans/specs/profiles-scenes.md diff --git a/plans/later/ice-box.md b/plans/later/ice-box.md new file mode 100644 index 0000000..e411917 --- /dev/null +++ b/plans/later/ice-box.md @@ -0,0 +1,20 @@ +# Later — ice box + +Ideas acknowledged, not committed. Promote to `next/` deliberately. + +- **`0fd9:00b6` Wave XLR MK.2 revision support** — blocked on hardware. + CryoByte33/openwave decoded it (UAC2, different control scheme). Unblocks + via a community `probe dump`/`watch` capture; docs/hardware-support.md + already asks for it. Diagnostics export (now-sprint) lowers the bar. +- **Wave XLR Pro support** — blocked on hardware; openxlr has the protocol + documented (`docs/wave-xlr-pro-protocol.md` in their tree) — a port + candidate with credit, same as the CryoByte33 borrowings. +- **Scene switching from the tray menu** — after profiles/scenes v1. +- **Restructuring scenes** — scenes that add/remove mixes and sources, not + just set levels. Only if v1 usage shows the need. +- **Audio flow visualization** — openxlr has live flow viz; OpenWave's + matrix arguably *is* the visualization. Revisit only on user ask. +- **Compressor/expander DSP** — second wave of the DSP chain (`plans/next/dsp-chain.md`). +- **Flatpak** — metainfo.xml exists now; a manifest is the missing piece. + Sandboxed USB + pkexec setup are real obstacles; investigate before + promising. diff --git a/plans/next/dsp-chain.md b/plans/next/dsp-chain.md new file mode 100644 index 0000000..22fb37d --- /dev/null +++ b/plans/next/dsp-chain.md @@ -0,0 +1,37 @@ +# Next: Host-side DSP chain + +Gap: openxlr offers host-side DSP (high-pass 80/120 Hz, ClipGuard-style +limiter, compressor/expander via LADSPA) for devices without onboard +effects; Wave Link has the full VST/AU stack. OpenWave has none +(`docs/comparison.md`, "Hardware control"). + +Parked in `next/` because it is the largest item and Phase 1–3 of +`plans/now/todo.md` should land first. Promote by moving this file's task +list into `plans/now/todo.md` and expanding into a full spec in +`plans/specs/`. + +## Shape (to be spec'd before promotion) + +- **Mechanism**: `libpipewire-module-filter-chain` node inserted between a + microphone capture row and its cells — fits the existing architecture + (ordinary PipeWire objects, no custom audio code). LADSPA `swh-plugins` + as optional dependency, exactly openxlr's approach; builtin filter-chain + plugins (`bq_highpass` etc.) cover the high-pass with zero new deps. +- **v1 scope**: per-microphone-row toggle set — high-pass (80/120 Hz), + hard limiter at −3 dB ("clip guard"). Compressor/expander later. +- **Where it lives**: filter-chain config generated like the mix sinks + (`setup.py` render path), node named `openwave_fx_`, reconciled + by `Mixer` like any other node; per-row FX popover in the matrix UI. +- **Persistence**: per-source FX settings in `sources.json` (they are + source identity, like trim). +- **Testing**: reconcile decisions against FakePipeWire (spawn/despawn/ + relink when FX toggles); the audible result is hardware-verified like + the rest of the routing. + +## Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| Latency added in the mic path | High | builtin biquads first; measure with `pw-top` before/after; FX off by default | +| filter-chain node caught by default-sink election / claiming | Med | same `priority.session=0` + naming-sweep treatment as intake sinks | +| swh-plugins missing at runtime | Low | builtin-only v1; LADSPA features degrade with a visible "plugin missing" state | diff --git a/plans/now/todo.md b/plans/now/todo.md new file mode 100644 index 0000000..967ae0b --- /dev/null +++ b/plans/now/todo.md @@ -0,0 +1,192 @@ +# Now — active sprint + +> Status 2026-08-30: Tasks 1–8 implemented, suite green (282 tests). +> Verified live: checkout app running (systemd unit `openwave-checkout`), +> 11 actions on the bus, full scene save/move/recall/delete round-trip over +> gdbus with the Dock attached — PASS. Remaining: scene recall after a real +> reboot, click Export diagnostics once, release.yml's AUR job proves +> itself on the next tag. + +Source: `docs/comparison.md` gap analysis vs openxlr / Wave Link (2026-08-30). +Specs live in `plans/specs/`. Done items get checked, re-linked to `docs/`, +then removed from this file. + +## Phase 1: Hygiene (quirk-fix batch) + +### Task 1: Derive udev_installed() from PROFILES + +**Description:** `wavexlr/setup.py udev_installed()` checks only `007d` and +`0070`, so an MK.2/Dock (`00a6`) owner re-runs first-run setup forever. +Identical bug class already happened once for `0070` (documented in +`flake.nix` postInstall comment). Derive both `UDEV_RULES` and the check +from `profiles.PROFILES` so a new device can never miss either. + +**Acceptance criteria:** +- [x] `udev_installed()` requires every `PROFILES` pid in the rule file +- [x] `UDEV_RULES` generated from `PROFILES` (single source of truth) +- [x] Unit test: every profile pid appears in rules and in the check + +**Verification:** `python3 -m unittest discover -s tests -t .` +**Dependencies:** None +**Files:** `wavexlr/setup.py`, `tests/test_config_render.py` (or new test file) +**Scope:** S + +### Task 2: Unify desktop-entry identity + +**Description:** `wavexlr/desktop.py:17-21` generates the pre-rename tagline +("Elgato Wave control for Linux") and generic icon; shipped +`wavexlr.desktop` says "The audio mixing matrix for Linux" with +`Icon=openwave`. Whichever entry the user gets depends on install path. +Make `desktop.py` the single source: same name/comment/categories as the +shipped file, `Icon=openwave` when the themed icon resolves, generic +fallback otherwise. + +**Acceptance criteria:** +- [x] Generated entry and `wavexlr.desktop` agree on Name/Comment/Categories +- [x] Icon falls back cleanly on a checkout with no installed icons +- [x] `tests/test_desktop.py` pins the generated content + +**Verification:** suite + launch from checkout, check drawer entry +**Dependencies:** None +**Files:** `wavexlr/desktop.py`, `wavexlr.desktop`, `tests/test_desktop.py` +**Scope:** S + +### Task 3: Single owner for GitHub Releases + +**Description:** `build.yml` (manual) pushes a `v*` tag — which triggers +`release.yml` — *and* runs `gh release create --draft` on the same tag. +Nothing coordinates them. Make `release.yml` the only workflow that creates +Releases; `build.yml` keeps the PKGBUILD bump + AUR publish and stops +creating releases. + +**Acceptance criteria:** +- [x] `build.yml` no longer calls `gh release create` +- [x] One tag push → exactly one Release +- [x] CONTRIBUTING.md "known quirk" paragraph updated to describe the fixed flow + +**Verification:** `workflow_dispatch` dry-run of release.yml (publishes no Release); next real tag +**Dependencies:** None +**Files:** `.github/workflows/build.yml`, `CONTRIBUTING.md` +**Scope:** S + +### Checkpoint: Hygiene +- [x] Suite green, `compileall` clean +- [ ] First-run setup no longer re-prompts on an MK.2-only machine (unit-tested; confirm once on the real machine) + +## Phase 2: Diagnostics export + +### Task 4: `wavexlr/diag.py` collector + +Spec: `plans/specs/diagnostics-export.md` + +**Description:** One command gathers everything a device bug report needs: +versions, detected profile, config/devinfo dump (via the GUI's own handle or +probe), `pw-dump` excerpt of openwave nodes, `wpctl status`, service state, +udev check, recent daemon journal. Plain-text bundle, secrets-free. + +**Acceptance criteria:** +- [x] `python3 -m wavexlr.diag` writes one timestamped `.txt` and prints its path +- [x] Runs without hardware and without the daemon (sections say "absent", never traceback) +- [x] No serial-number redaction needed beyond what README already publishes; no config-file contents with user app names unless `--full` + +**Verification:** run with and without device; unit test on section assembly with faked collectors +**Dependencies:** None +**Files:** `wavexlr/diag.py` (new), `tests/test_diag.py` (new) +**Scope:** M + +### Task 5: Export button + docs + +**Description:** "Export diagnostics" button in the sidebar service section; +saves via file dialog. README "Reporting problems" section points at it +first, probe second. + +**Acceptance criteria:** +- [x] Button produces the same bundle as the CLI +- [x] README + docs/hardware-support.md reference it + +**Verification:** manual click; suite +**Dependencies:** Task 4 +**Files:** `wavexlr/app.py`, `README.md`, `docs/hardware-support.md` +**Scope:** S + +## Phase 3: Profiles / scenes (v1) + +Spec: `plans/specs/profiles-scenes.md` — read it before starting. + +### Task 6: Scene store + capture/apply in Mixer + +**Description:** `wavexlr/scenes.py` (NOT `profiles.py` — that name is taken +by device protocol profiles): named snapshots of trims, source mutes, cell +sends/mutes, mix outputs, mix master volumes. Capture from live state; +apply through the existing setter paths so reconcile stays authoritative. + +**Acceptance criteria:** +- [x] `~/.config/openwave/scenes.json`, corrupt-file recovery same as mixes.py +- [x] Apply tolerates a scene naming a source/mix that no longer exists (skips, reports) +- [x] Reconcile tests cover apply (FakePipeWire call-sequence assertions) + +**Verification:** suite; manual save/recall across restart +**Dependencies:** None (parallel-safe with Phase 2) +**Files:** `wavexlr/scenes.py` (new), `wavexlr/mixer.py`, `tests/test_scenes.py` (new) +**Scope:** M + +### Task 7: Hardware state in scenes + +**Description:** Extend scene payload with device state (gain, mute, +phantom, low-Z, HP volume) keyed by profile key; applied only when that +device is connected, respecting gain lock. + +**Acceptance criteria:** +- [x] Scene with hardware section applies on matching device, silently skips otherwise +- [x] Gain lock wins over a scene's gain value +- [x] No-hardware test stays green + +**Verification:** suite + on-hardware check +**Dependencies:** Task 6 +**Files:** `wavexlr/scenes.py`, `wavexlr/app.py` +**Scope:** S + +### Task 8: Scene UI + D-Bus + +**Description:** Header-bar scene menu (save current as…, apply, delete) and +two remote actions: `apply-scene` (`s`), `save-scene` (`s`), plus a `scenes` +state action — same activate/describe pattern as `source-groups`. + +**Acceptance criteria:** +- [x] Scenes drivable from `gdbus` (Stream Deck-ready) +- [x] README Remote control table + docs/comparison.md updated (profiles row goes 🟢) + +**Verification:** `gdbus call` round-trip; suite +**Dependencies:** Task 6 (Task 7 optional) +**Files:** `wavexlr/app.py`, `README.md`, `docs/comparison.md` +**Scope:** M + +## Phase 4: Multiple simultaneous devices (added mid-sprint, implemented) + +### Task 9: Multi-device support end to end + +**Description:** Every connected Wave opened at once — same-model pairs +included. `device.scan()` walks the bus (libusb device list), targeted +`connect(profile, bus, addr)` opens a specific unit and pins its ALSA card +via `usbbus`; app holds `_devs` list with a sidebar Device dropdown +(visible only with 2+), polls and ALSA-syncs all units, sysfs watch +(`present_units()`) catches hotplug while others stay connected; daemon +refactored to one `_Pin` per source with worst-state aggregation; scenes +hardware keyed `profile:serial` with legacy fallback; tray muted = any +device muted; diag dumps every unit. + +**Acceptance criteria:** +- [x] `scan()` reports two identical models separately (`tests/test_device_scan.py`) +- [x] Daemon pins every Wave incl. XLR Dock (whose node name the old match missed) — `tests/test_audio_pins.py`; verified live: two pins on the real Wave XLR + Dock +- [x] Scene hardware entries keyed by serial, legacy scenes still apply (`tests/test_scenes.py`) +- [x] App verified live holding both devices (diag showed both handles held, cards 4 and 3) +- [x] Start-in-tray verified live: `--hide` stays tray-only, one StatusNotifierItem, window summonable + +**Files:** `wavexlr/device.py`, `wavexlr/app.py`, `wavexlr/audio.py`, `wavexlr/scenes.py`, `wavexlr/diag.py`, tests +**Scope:** L (user-requested mid-sprint) + +### Checkpoint: Sprint complete +- [ ] Suite green on 3.10 + 3.13 (3.14 local ✓; CI on push) +- [ ] Scene saved → reboot → recalled, hardware included (manual) +- [ ] Diagnostics bundle attached to a test issue reads clean (manual) +- [x] `docs/` updated; done items re-linked and removed from this file diff --git a/plans/specs/diagnostics-export.md b/plans/specs/diagnostics-export.md new file mode 100644 index 0000000..4aef007 --- /dev/null +++ b/plans/specs/diagnostics-export.md @@ -0,0 +1,53 @@ +# Spec: Diagnostics export + +Gap: openxlr ships one-click diagnostics export (`docs/comparison.md`, +"Quality of life"); OpenWave asks reporters to run probe by hand. This also +feeds the `00b6` hardware hunt (`docs/hardware-support.md` call-to-action). + +## Deliverable + +`python3 -m wavexlr.diag` → one timestamped plain-text file +(`openwave-diag-YYYYMMDD-HHMMSS.txt`), path printed; plus an "Export +diagnostics" button in the sidebar's service section writing the same +bundle via a save dialog. + +## Sections + +| Section | Source | Notes | +|---|---|---| +| Versions | OpenWave version, Python, GTK/Adwaita, PipeWire, distro | best-effort | +| Device | detected profile, vid:pid, fw/API/serial, config + devinfo hexdump | via the running GUI's handle when open, else direct connect (probe path) | +| USB | supported IDs present on the bus (`wave_present` logic, per-pid) | | +| udev | `udev_installed()` result + which rule file matched | | +| Service | init system, installed/running state, keepalive watchdog state | `service.py` | +| Journal | last ~100 lines of the user daemon unit (systemd only) | `journalctl --user -u openwave` | +| PipeWire | openwave nodes from `pw-dump` (names, states, links), `wpctl status` | filter to `openwave_*` + Elgato nodes | +| Config | which config files exist + sizes + parse-ok flag | contents only with `--full` (app names are personal) | + +Every collector is isolated: a failed or absent source prints +`
: unavailable ()` — never a traceback, never a hang +(subprocess timeouts like the rest of the codebase, 3 s). + +## Design decisions + +- **Plain text, one file.** Attachable to a GitHub issue inline; no tarball + until something binary needs shipping. +- **Privacy default-on**: no config contents, no full `pw-dump` (stream + names reveal running apps) without `--full`. Serials stay — they are + already how hardware reports are matched. +- **Reuse, don't duplicate**: hexdump from `probe.py`, presence from + `device.wave_present`, service state from `service.py`, paths from + `paths.py`. The module is assembly, not new probing. +- **GUI handle sharing**: firmware serves one process; when the GUI is open + the CLI cannot read the device. CLI says so and continues; the in-app + button uses the GUI's own handle, so it always gets device data. This is + why the button exists and is the recommended path in README. + +## Verification + +- Unit: assemble bundle with all collectors faked (present/absent/raising); + assert section headers, no exception escapes. +- Manual: run CLI with device attached + GUI closed, GUI open (device + section says held), no device at all. +- Update README "Reporting problems" + hardware-support call-to-action to + lead with the export. diff --git a/plans/specs/profiles-scenes.md b/plans/specs/profiles-scenes.md new file mode 100644 index 0000000..adda62e --- /dev/null +++ b/plans/specs/profiles-scenes.md @@ -0,0 +1,79 @@ +# Spec: Profiles / scenes + +Gap: openxlr has named scenes recallable from UI or API (`docs/comparison.md`, +"Scenes, control surfaces, API"); OpenWave has one persistent state. + +## What a scene is + +A named snapshot the user recalls as one gesture — "Streaming", +"Recording", "Late night". v1 payload: + +```json +{ + "scenes": { + "streaming": { + "name": "Streaming", + "sources": {"": {"trim": 0.8, "muted": false}}, + "cells": {"/": {"send": 0.5, "muted": false}}, + "outputs": {"": "alsa_output...."}, + "volumes": {"openwave_personal_mix": 0.65}, + "hardware": {"wave_xlr": {"gain_raw": 20480, "mute": false, + "phantom": true, "low_z": false, "hp_db": -12.0}} + } + } +} +``` + +Deliberately **not** in v1: mix/source *definitions* (creating or deleting +rows/columns on scene switch). A scene sets levels on the matrix that +exists; it does not restructure it. Restructuring scenes = later horizon, +only if wanted after v1 use. + +## Design decisions + +- **Module name `scenes.py`**, store `~/.config/openwave/scenes.json`. + `profiles.py` is taken by device protocol profiles — do not overload the + word "profile" in code; UI copy may still say "profile" if it reads + better (decide at UI task). +- **Apply goes through the window's existing paths** (`Mixer.set_cell`, + source trim setters, device setters), never by writing config files — + same rule as the D-Bus surface and for the same reason: reconcile + re-applies `send × trim`, and the GUI holds the only USB handle. +- **Partial apply is normal, not an error.** A scene naming a source/mix + that no longer exists skips those entries and reports what it skipped + (toast in UI, log line from D-Bus). A scene's hardware section applies + only when a device with that profile key is connected. +- **Gain lock wins.** A locked gain slider rejects the scene's gain the + same way it rejects a drag; everything else in the scene still applies. +- **Capture reads live state**, not stored state — same principle as mix + master persistence: whatever moved a fader, that is the value the scene + should hold. +- **Store shape follows `mixes.py`**: seeded empty, corrupt file preserved + as `.corrupt` and replaced, whole-file rewrite on save. + +## Remote surface + +Three new `org.gtk.Actions`, same conventions as the existing seven: + +| Action | Parameter | Does | +|---|---|---| +| `apply-scene` | `s` scene id | Applies a scene (partial-apply rules above) | +| `save-scene` | `s` scene id/name | Captures current state into that scene | +| `scenes` | — | State: scene ids + names, activate-then-describe | + +`snapshot` already exposes everything a scene holds, so an external tool +can diff scene-vs-live without new actions. + +## Open questions + +- Does a scene switch belong on the tray menu? (Probably yes, after v1.) +- Should `apply-scene` report skipped entries over the bus, or is the log + enough? (v1: log; revisit if openwave-streamdeck wants feedback.) + +## Verification + +- Reconcile tests with FakePipeWire: apply produces exactly the expected + set-volume/mute call sequence; skipped entries produce none. +- Round-trip: save scene → restart app → apply → `snapshot` matches saved + payload (minus skipped hardware when absent). +- On hardware: phantom/gain/HP recalled; gain-lock case. From 3b84b0200cc485a8a798ee3b17d2b52a8e3e9008 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:27 -0500 Subject: [PATCH 68/99] Derive the udev rules and their check from the device profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twice a device was added to PROFILES while udev stayed hardcoded to an older subset — 0070 first (the flake documents it), then 00a6 — and the symptom both times was first-run setup re-prompting forever on the device the check skipped. Rules and check now both derive from PROFILES, so a third recurrence cannot compile quietly, and the tests pin it. The flake's rule extraction switches from literal_eval on the AST to an import, since UDEV_RULES is computed now. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- flake.nix | 19 ++++++------ tests/test_setup_udev.py | 66 ++++++++++++++++++++++++++++++++++++++++ wavexlr/setup.py | 17 +++++++---- 3 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 tests/test_setup_udev.py diff --git a/flake.nix b/flake.nix index 367005c..54d13d3 100644 --- a/flake.nix +++ b/flake.nix @@ -48,18 +48,17 @@ # and the in-app permission check passes out of the box. # # Generated from setup.py's UDEV_RULES rather than restated, because - # udev_installed() requires *every* product ID to be present. This - # was hardcoded to 007d alone while UDEV_RULES also carries 0070 - # (Wave:3), so the check failed permanently: run_setup() called - # install_udev() on every launch, pkexec-wrote the same file the - # package already owns, and returned early on failure -- meaning the - # WirePlumber and mix-sink configs after it never got installed - # either. On NixOS that pkexec cannot succeed regardless, since - # /etc/udev/rules.d/99-openwave.rules is a read-only store symlink. + # udev_installed() requires *every* product ID to be present, and + # both it and the rules now derive from profiles.PROFILES — a new + # device cannot be missing from either. (The old literal_eval AST + # extraction is gone: UDEV_RULES is computed, so it is imported.) + # On NixOS the in-app pkexec cannot write the rule regardless, + # since /etc/udev/rules.d/99-openwave.rules would be a read-only + # store symlink — consume this via services.udev.packages instead. postInstall = '' mkdir -p $out/lib/udev/rules.d - ${pythonEnv}/bin/python3 -c 'import ast,sys; t=ast.parse(open(sys.argv[1]).read()); v=next(n.value for n in t.body if isinstance(n,ast.Assign) and any(getattr(x,"id",None)=="UDEV_RULES" for x in n.targets)); sys.stdout.write("\n".join(ast.literal_eval(v))+"\n")' \ - "$out/${sitePkgs}/wavexlr/setup.py" \ + PYTHONPATH=$out/${sitePkgs} ${pythonEnv}/bin/python3 -c \ + 'from wavexlr.setup import UDEV_RULES; print("\n".join(UDEV_RULES))' \ > $out/lib/udev/rules.d/99-openwave.rules # setup.py looks for the WirePlumber and mix-sink configs next to diff --git a/tests/test_setup_udev.py b/tests/test_setup_udev.py new file mode 100644 index 0000000..1aba691 --- /dev/null +++ b/tests/test_setup_udev.py @@ -0,0 +1,66 @@ +"""The udev rules and the installed-check must both cover every profile. + +Twice now a device was added to PROFILES while one of the two stayed +hardcoded to an older subset: 0070 was missing from udev_installed() (the +flake documents it), then 00a6 was. The symptom is first-run setup +re-prompting forever on the device the check skipped. Both now derive from +PROFILES; these tests pin that a third recurrence cannot compile quietly. +""" + +import os +import tempfile +import unittest +from unittest import mock + +from wavexlr import setup +from wavexlr.profiles import PROFILES + + +class TestUdevRules(unittest.TestCase): + def test_every_profile_has_a_rule(self): + text = "\n".join(setup.UDEV_RULES) + for p in PROFILES: + self.assertIn(f'ATTR{{idProduct}}=="{p.pid:04x}"', text) + self.assertIn(f'ATTR{{idVendor}}=="{p.vid:04x}"', text) + + def test_one_rule_per_profile(self): + self.assertEqual(len(setup.UDEV_RULES), len(PROFILES)) + + def test_rules_carry_no_inline_comment(self): + # udev only ignores lines *starting* with '#'; a trailing comment + # would be part of the rule and break it. + for rule in setup.UDEV_RULES: + self.assertNotIn("#", rule) + + +class TestUdevInstalled(unittest.TestCase): + def _check(self, content): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "99-openwave.rules") + if content is not None: + with open(path, "w") as f: + f.write(content) + with mock.patch.object(setup, "UDEV_PATH", path), \ + mock.patch.object(setup, "UDEV_PATH_OLD", + os.path.join(d, "absent")): + return setup.udev_installed() + + def test_complete_rules_pass(self): + self.assertTrue(self._check("\n".join(setup.UDEV_RULES))) + + def test_any_missing_profile_fails(self): + for skipped in PROFILES: + content = "\n".join( + r for p, r in zip(PROFILES, setup.UDEV_RULES) if p is not skipped + ) + self.assertFalse( + self._check(content), + f"udev_installed() ignored a missing {skipped.display_name}", + ) + + def test_no_file_fails(self): + self.assertFalse(self._check(None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/setup.py b/wavexlr/setup.py index a541225..91473dd 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -7,11 +7,16 @@ import threading from . import paths, service - -UDEV_RULES = ( - 'SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="007d", MODE="0666"', # Wave XLR - 'SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="00a6", MODE="0666"', # Wave XLR MK.2 - 'SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="0070", MODE="0666"', # Wave:3 +from .profiles import PROFILES + +# One rule per supported device, derived from PROFILES so a new profile can +# never be missing here — or in udev_installed() below, which once hardcoded +# a subset and made first-run setup re-prompt forever on the devices it +# skipped (0070 originally, then 00a6; flake.nix documents the first). +UDEV_RULES = tuple( + 'SUBSYSTEM=="usb", ATTR{idVendor}=="%04x", ATTR{idProduct}=="%04x", ' + 'MODE="0666"' % (p.vid, p.pid) + for p in PROFILES ) UDEV_PATH = "/etc/udev/rules.d/99-openwave.rules" UDEV_PATH_OLD = "/etc/udev/rules.d/99-wavexlr.rules" @@ -40,7 +45,7 @@ def udev_installed(): try: with open(path) as f: content = f.read() - if all(pid in content for pid in ("007d", "0070")): + if all(f"{p.pid:04x}" in content for p in PROFILES): return True except (FileNotFoundError, PermissionError): continue From e6bd39dc3ac1cfd9ab142d3e74561313c3ea43a6 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:28 -0500 Subject: [PATCH 69/99] Give the generated desktop entry the packaged one's identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry desktop.py writes on launch still carried the pre-rename tagline and a stock icon, so the app introduced itself differently depending on how it was installed. Both entries now agree on name, comment and categories; the icon is the themed one when it resolves and falls back to the stock microphone on a checkout with no icons installed, because an unresolvable icon renders as the broken gear — worse than generic. Tests pin generated-versus-packaged agreement. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_desktop.py | 42 +++++++++++++++++++++++++++++++++++++++++- wavexlr.desktop | 7 +++++-- wavexlr/desktop.py | 29 ++++++++++++++++++++++++++--- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/tests/test_desktop.py b/tests/test_desktop.py index aa23eef..2280111 100644 --- a/tests/test_desktop.py +++ b/tests/test_desktop.py @@ -17,9 +17,13 @@ class TempHome(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self._env = {k: os.environ.get(k) - for k in ("XDG_DATA_HOME", "XDG_CONFIG_HOME")} + for k in ("XDG_DATA_HOME", "XDG_CONFIG_HOME", + "XDG_DATA_DIRS")} os.environ["XDG_DATA_HOME"] = os.path.join(self._tmp.name, "data") os.environ["XDG_CONFIG_HOME"] = os.path.join(self._tmp.name, "config") + # Point system data dirs into the sandbox too, so a machine that has + # OpenWave's icons installed for real cannot leak into the tests. + os.environ["XDG_DATA_DIRS"] = os.path.join(self._tmp.name, "sysdata") def tearDown(self): for key, value in self._env.items(): @@ -146,5 +150,41 @@ def test_autostart_and_the_menu_entry_are_separate_files(self): self.assertTrue(os.path.isfile(desktop.menu_entry_path())) +class Identity(TempHome): + """The generated entry and the packaged wavexlr.desktop must agree. + + They drifted once: the generated one kept the pre-rename tagline and a + generic icon, so the app introduced itself differently depending on how + it was installed. + """ + + def _packaged(self): + path = os.path.join(os.path.dirname(desktop.__file__), + "..", "wavexlr.desktop") + entries = {} + for line in open(path): + if "=" in line: + key, value = line.strip().split("=", 1) + entries[key] = value + return entries + + def test_name_comment_categories_match_the_packaged_entry(self): + desktop.ensure_menu_entry() + generated = open(desktop.menu_entry_path()).read() + packaged = self._packaged() + for key in ("Name", "Comment", "Categories", "StartupWMClass"): + self.assertIn(f"{key}={packaged[key]}", generated) + + def test_icon_falls_back_when_the_themed_one_is_absent(self): + self.assertEqual(desktop.icon_name(), desktop.ICON_FALLBACK) + + def test_icon_is_the_themed_one_when_installed(self): + icon = os.path.join(os.environ["XDG_DATA_DIRS"], "icons", "hicolor", + "scalable", "apps", "openwave.svg") + os.makedirs(os.path.dirname(icon)) + open(icon, "w").close() + self.assertEqual(desktop.icon_name(), desktop.ICON) + + if __name__ == "__main__": unittest.main() diff --git a/wavexlr.desktop b/wavexlr.desktop index ee39a89..d6909fe 100644 --- a/wavexlr.desktop +++ b/wavexlr.desktop @@ -1,7 +1,10 @@ [Desktop Entry] +Type=Application Name=OpenWave Comment=The audio mixing matrix for Linux Exec=openwave Icon=openwave -Type=Application -Categories=Audio;Settings; +Categories=AudioVideo;Audio;Mixer; +Terminal=false +StartupWMClass=com.github.openwave +X-GNOME-UsesNotifications=true diff --git a/wavexlr/desktop.py b/wavexlr/desktop.py index 0b97cff..0fdde25 100644 --- a/wavexlr/desktop.py +++ b/wavexlr/desktop.py @@ -12,8 +12,12 @@ APP_ID = "openwave" NAME = "OpenWave" -COMMENT = "Elgato Wave control for Linux" -ICON = "audio-input-microphone" +# Identity must match the packaged wavexlr.desktop: whichever entry the user +# ends up with depends only on install path, so the two disagreeing means the +# app renames itself depending on how it was installed. +COMMENT = "The audio mixing matrix for Linux" +ICON = "openwave" +ICON_FALLBACK = "audio-input-microphone" # One main category only. AudioVideo plus Settings validates, but # desktop-file-validate warns it may list the app twice in the menu, # and a mixer belongs under Audio rather than under system settings. @@ -55,6 +59,25 @@ def launch_command(): return f"env PYTHONPATH={checkout} {sys.executable} -m wavexlr" +def icon_name(): + """The themed icon when it is installed, a stock one when it is not. + + A run-in-place checkout has no openwave.svg in any icon directory, and a + .desktop entry naming an unresolvable icon renders as the generic broken + gear — worse than the stock microphone. The check mirrors where the + Makefile and the Nix wrapper put the icon: hicolor under each XDG data + dir. + """ + data_dirs = [_data_home()] + ( + os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" + ).split(":") + for base in filter(None, data_dirs): + if os.path.isfile(os.path.join( + base, "icons", "hicolor", "scalable", "apps", f"{ICON}.svg")): + return ICON + return ICON_FALLBACK + + def _render(exec_command, autostart=False): lines = [ "[Desktop Entry]", @@ -62,7 +85,7 @@ def _render(exec_command, autostart=False): f"Name={NAME}", f"Comment={COMMENT}", f"Exec={exec_command}", - f"Icon={ICON}", + f"Icon={icon_name()}", f"Categories={CATEGORIES}", "Terminal=false", # Without this the tray icon and the window are two entries in the From 77163ebab0b8d7ff9336aa3ccc6f7f8917b8935a Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:28 -0500 Subject: [PATCH 70/99] Let one tag push own the whole release, AUR included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.yml force-pushed tags, created a draft release on the same tag release.yml was already publishing for, and since the PKGBUILD began sourcing release tarballs it could only ever ship a stale checksum: the tarball it pins does not exist until release.yml builds it. Gone. release.yml now finishes the job itself — publish the Release, point the PKGBUILD at the released tarball (pkgver and sha256, committed back to the default branch), and push to AUR, skipping quietly on a fork without the AUR key. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- .github/workflows/build.yml | 120 ---------------------------------- .github/workflows/release.yml | 80 ++++++++++++++++++++++- 2 files changed, 78 insertions(+), 122 deletions(-) delete mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index fb9fa80..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Build - -on: - workflow_dispatch: - inputs: - bump: - description: "Version bump type" - required: true - default: "patch" - type: choice - options: - - patch - - minor - - major - aur: - description: "Publish to AUR?" - required: true - default: "yes" - type: choice - options: - - "yes" - - "no" - -jobs: - release: - name: Release - runs-on: ubuntu-latest - - permissions: - contents: write - - outputs: - version: ${{ steps.bump.outputs.version }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Bump version - id: bump - run: | - OLD_VER=$(grep -oP 'pkgver=\K.*' PKGBUILD) - - IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VER" - case "${{ inputs.bump }}" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - NEW_VER="${MAJOR}.${MINOR}.${PATCH}" - - sed -i "s/pkgver=$OLD_VER/pkgver=$NEW_VER/" PKGBUILD - sed -i "s/pkgrel=.*/pkgrel=1/" PKGBUILD - - echo "version=$NEW_VER" >> "$GITHUB_OUTPUT" - echo "Bumped $OLD_VER → $NEW_VER" - - - name: Commit and tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add PKGBUILD - git commit -m "Bump version to v${{ steps.bump.outputs.version }}" - git tag "v${{ steps.bump.outputs.version }}" - git push - git push origin "v${{ steps.bump.outputs.version }}" --force - - - name: Create draft release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: v${{ steps.bump.outputs.version }} - run: | - BODY="### Changes - + No changes." - - gh release create "$TAG" --draft --title "$TAG" --notes "$BODY" - - aur: - name: Publish to AUR - needs: release - if: inputs.aur == 'yes' - runs-on: ubuntu-latest - container: archlinux:base-devel - - steps: - - name: Install dependencies - run: pacman -Syu --noconfirm git openssh - - - name: Setup SSH - run: | - mkdir -p ~/.ssh - echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan -v -t ed25519,rsa aur.archlinux.org > ~/.ssh/known_hosts 2>&1 || true - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: v${{ needs.release.outputs.version }} - - - name: Push to AUR - env: - VERSION: ${{ needs.release.outputs.version }} - GIT_SSH_COMMAND: "ssh -i ~/.ssh/aur -o StrictHostKeyChecking=accept-new" - run: | - git config --global user.name "${{ secrets.AUR_USERNAME }}" - git config --global user.email "${{ secrets.AUR_EMAIL }}" - git config --global --add safe.directory '*' - - git clone ssh://aur@aur.archlinux.org/openwave.git aur-repo - cp PKGBUILD aur-repo/PKGBUILD - - useradd -m builder - chown -R builder:builder aur-repo - cd aur-repo - su builder -c "makepkg --printsrcinfo" > .SRCINFO - - git add PKGBUILD .SRCINFO - git commit -m "Update to v${VERSION}" - git push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7901a9c..a1774cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,10 +5,14 @@ name: Release # messages; this one writes prose commit subjects, which semantic-release # reads as "never release". So the version is the tag -- push v1.2.3 and # this builds the release objects -- and everything downstream of the -# version (gate on the suite, build artifacts, publish a Release) is as -# automatic as the plugin's. +# version (gate on the suite, build artifacts, publish a Release, point the +# PKGBUILD at it and publish to AUR) is as automatic as the plugin's. # # git tag v1.2.3 && git push v1.2.3 +# +# This workflow is the only one that creates Releases and tags carry no other +# automation, so one tag push can never produce two Releases. The AUR job +# no-ops quietly when the AUR_SSH_PRIVATE_KEY secret is absent (forks). on: push: @@ -126,3 +130,75 @@ jobs: --title "OpenWave ${GITHUB_REF#refs/tags/v}" \ --generate-notes \ openwave-*.tar.gz openwave_*.deb sha256sums.txt + + aur: + name: Publish to AUR + needs: [publish] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + container: archlinux:base-devel + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Install tools + run: pacman -Syu --noconfirm git openssh + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - uses: actions/download-artifact@v4 + with: + name: release-objects + + # The artifact tarball IS the file the Release serves, so its checksum + # is the one the PKGBUILD must pin. The old manual flow bumped pkgver + # without touching sha256sums, which could only ever be stale: the + # tarball it points at does not exist until this workflow publishes it. + - name: Point the PKGBUILD at this release + run: | + V="${GITHUB_REF#refs/tags/v}" + SUM=$(sha256sum "openwave-${V}.tar.gz" | cut -d' ' -f1) + sed -i "s/^pkgver=.*/pkgver=${V}/" PKGBUILD + sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SUM}')/" PKGBUILD + + - name: Commit the PKGBUILD back + run: | + git config --global --add safe.directory "$PWD" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add PKGBUILD + git diff --cached --quiet || git commit -m "Point the PKGBUILD at v${GITHUB_REF#refs/tags/v}" + git push + + - name: Push to AUR + env: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + AUR_USERNAME: ${{ secrets.AUR_USERNAME }} + AUR_EMAIL: ${{ secrets.AUR_EMAIL }} + run: | + if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then + echo "AUR_SSH_PRIVATE_KEY not set; skipping AUR publish." + exit 0 + fi + V="${GITHUB_REF#refs/tags/v}" + mkdir -p ~/.ssh + echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + ssh-keyscan -t ed25519,rsa aur.archlinux.org > ~/.ssh/known_hosts 2>&1 || true + export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=accept-new" + git config --global user.name "$AUR_USERNAME" + git config --global user.email "$AUR_EMAIL" + git config --global --add safe.directory '*' + + git clone ssh://aur@aur.archlinux.org/openwave.git aur-repo + cp PKGBUILD aur-repo/PKGBUILD + useradd -m builder + chown -R builder:builder aur-repo + cd aur-repo + su builder -c "makepkg --printsrcinfo" > .SRCINFO + git add PKGBUILD .SRCINFO + git commit -m "Update to v${V}" + git push From b27f67e919a0a1682fe315ef15e3e43c364b6ee7 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:51 -0500 Subject: [PATCH 71/99] Hold every Wave on the bus, and survive one being yanked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan() walks the USB device list so two units of one model are two results, and connect(profile, bus, addr) opens a specific unit, pinning its ALSA card through usbbus so same-model pairs cannot share a card. present_units() is the cheap sysfs diff a watch can poll. The unplug path stops being a crash: disconnect happens under the transfer lock, and a transfer after disconnect raises instead of handing libusb the cleared handle — which it does not NULL-check, so an unplug that landed between the two transfers of one poll was a segfault (confirmed from the core dump, on real hardware, mid-poll). The capture-fix daemon keeps one watched pw-cat pin per device, with the worst pin's state winning the aggregate so a wedged device cannot hide behind a healthy one. Its source match also finally covers the XLR Dock, whose "Elgato_XLR_Dock_" node name the old "Elgato_Wave_" stem silently skipped — that device has been running with no keepalive at all. The ALSA read-back inside get_all() decimates to every fifth poll: two amixer forks per device at 10 Hz was forty subprocess spawns a second for values that almost never move. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_audio_pins.py | 69 ++++++++ tests/test_device_scan.py | 77 +++++++++ wavexlr/audio.py | 336 ++++++++++++++++++++++---------------- wavexlr/device.py | 194 +++++++++++++++++++++- 4 files changed, 522 insertions(+), 154 deletions(-) create mode 100644 tests/test_audio_pins.py create mode 100644 tests/test_device_scan.py diff --git a/tests/test_audio_pins.py b/tests/test_audio_pins.py new file mode 100644 index 0000000..1b3df2e --- /dev/null +++ b/tests/test_audio_pins.py @@ -0,0 +1,69 @@ +"""Keepalive discovery and aggregation across multiple Wave devices. + +The old single-pin manager had two multi-device failures pinned here: its +source match caught only "Elgato_Wave_" (the XLR Dock enumerates as +"Elgato_XLR_Dock_" and silently got no keepalive at all), and one healthy +device could hide another's wedge. +""" + +import unittest +from unittest import mock + +from wavexlr import audio + + +def _node(name): + return {"type": "PipeWire:Interface:Node", + "info": {"props": {"node.name": name}}} + + +class Discovery(unittest.TestCase): + def test_finds_every_wave_family_node(self): + dump = [ + _node("alsa_input.usb-Elgato_Systems_Elgato_Wave_XLR_ABC-00.mono"), + _node("alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_DEF-00.mono"), + _node("alsa_input.usb-Elgato_Systems_Elgato_Wave_3_GHI-00.mono"), + ] + with mock.patch.object(audio, "_pw_dump", return_value=dump): + names = audio._get_source_node_names() + self.assertEqual(len(names), 3) + self.assertTrue(any("XLR_Dock" in n for n in names), + "the Dock must be pinned too") + + def test_other_hardware_is_left_alone(self): + dump = [ + _node("alsa_input.usb-Elgato_Systems_Game_Capture_HD60-00.mono"), + _node("alsa_input.usb-SteelSeries_Arctis_Nova-00.mono"), + _node("alsa_output.usb-Elgato_Systems_Elgato_Wave_XLR_A-00.st"), + ] + with mock.patch.object(audio, "_pw_dump", return_value=dump): + self.assertEqual(audio._get_source_node_names(), []) + + def test_duplicates_collapse(self): + n = _node("alsa_input.usb-Elgato_Systems_Elgato_Wave_XLR_A-00.mono") + with mock.patch.object(audio, "_pw_dump", return_value=[n, n]): + self.assertEqual(len(audio._get_source_node_names()), 1) + + +class Aggregation(unittest.TestCase): + def test_no_pins_is_absent(self): + self.assertEqual(audio._aggregate([]), (False, False, "absent")) + + def test_all_ok_is_healthy(self): + self.assertEqual(audio._aggregate(["ok", "ok"]), (True, True, "ok")) + + def test_one_wedged_device_cannot_hide_behind_a_healthy_one(self): + self.assertEqual(audio._aggregate(["ok", "wedged"]), + (True, False, "wedged")) + + def test_wedged_outranks_silent(self): + self.assertEqual(audio._aggregate(["silent", "wedged"]), + (True, False, "wedged")) + + def test_silent_alone_reports_silent(self): + self.assertEqual(audio._aggregate(["ok", "silent"]), + (True, False, "silent")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_device_scan.py b/tests/test_device_scan.py new file mode 100644 index 0000000..c42a65e --- /dev/null +++ b/tests/test_device_scan.py @@ -0,0 +1,77 @@ +"""Enumerating every supported Wave, including two of the same model. + +connect() with no arguments opens the first device of a vid:pid, which +made a second identical unit invisible; scan() walks the bus and reports +each one with its (bus, addr) so callers can open them individually. +""" + +import unittest +from unittest import mock + +from wavexlr import device +from wavexlr.profiles import PROFILES, WAVE3, WAVE_XLR_MK2 + + +def _fake_bus(entries): + """A _each_usb_device that visits the given (vid, pid, bus, addr).""" + def each(visit): + for vid, pid, bus, addr in entries: + visit(vid, pid, bus, addr, object()) + return each + + +class Scan(unittest.TestCase): + def test_two_identical_models_are_two_results(self): + bus = _fake_bus([ + (WAVE_XLR_MK2.vid, WAVE_XLR_MK2.pid, 1, 5), + (WAVE_XLR_MK2.vid, WAVE_XLR_MK2.pid, 3, 2), + ]) + with mock.patch.object(device, "_each_usb_device", bus): + found = device.scan() + self.assertEqual(len(found), 2) + self.assertEqual({(b, a) for _p, b, a in found}, {(1, 5), (3, 2)}) + + def test_unsupported_hardware_is_ignored(self): + bus = _fake_bus([ + (0x046D, 0x0825, 1, 4), # some webcam + (WAVE3.vid, 0x9999, 1, 6), # right vendor, unknown product + (WAVE3.vid, WAVE3.pid, 2, 3), + ]) + with mock.patch.object(device, "_each_usb_device", bus): + found = device.scan() + self.assertEqual([(p.key, b, a) for p, b, a in found], + [("wave3", 2, 3)]) + + def test_results_come_in_bus_order(self): + entries = [(p.vid, p.pid, bus, addr) + for (p, bus, addr) in zip(PROFILES, (9, 1, 5), (9, 1, 5))] + with mock.patch.object(device, "_each_usb_device", _fake_bus(entries)): + found = device.scan() + self.assertEqual([(b, a) for _p, b, a in found], + [(1, 1), (5, 5), (9, 9)]) + + +class ClosedHandle(unittest.TestCase): + """A transfer after disconnect must be an error, never a crash. + + get_all() releases the device lock between transfers, and unplug + handling can disconnect in that gap. libusb does not NULL-check its + handle argument, so before the guard this was a segfault that took the + whole app down the moment a device was unplugged mid-poll. + """ + + def test_read_on_a_cleared_handle_raises(self): + dev = device.WaveDevice() + dev.profile = WAVE_XLR_MK2 + with self.assertRaisesRegex(RuntimeError, "disconnected"): + dev._ctrl_read(0x0000, 34) + + def test_write_on_a_cleared_handle_raises(self): + dev = device.WaveDevice() + dev.profile = WAVE_XLR_MK2 + with self.assertRaisesRegex(RuntimeError, "disconnected"): + dev._ctrl_write(0x0000, b"\x00" * 34) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/audio.py b/wavexlr/audio.py index 98ff3a9..4c4b778 100644 --- a/wavexlr/audio.py +++ b/wavexlr/audio.py @@ -44,7 +44,14 @@ log = logging.getLogger("wavexlr.audio") -SOURCE_MATCH = "alsa_input.usb-Elgato_Systems_Elgato_Wave_" +# Every Wave family capture node. Two stems, not one "Elgato_" prefix, +# because Elgato also ships capture cards with audio inputs that must not +# be pinned; and not "Wave_" alone, because the XLR Dock (MK.2) enumerates +# as "Elgato_XLR_Dock_..." — the old single-stem match silently skipped it. +SOURCE_MATCHES = ( + "alsa_input.usb-Elgato_Systems_Elgato_Wave_", + "alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_", +) # Seconds without byte flow before we consider the keepalive wedged. At # 48 kHz mono s16 the healthy rate is ~96 kB/s, so even 1s of silence is @@ -136,72 +143,85 @@ def _source_is_muted(node_name): return False -def _get_source_node_name(): - """Get the full node name of the Elgato Wave source.""" +def _get_source_node_names(): + """Every Elgato Wave capture node currently present, in dump order.""" + names = [] for obj in _pw_dump(): if obj.get("type") != "PipeWire:Interface:Node": continue props = obj.get("info", {}).get("props", {}) name = props.get("node.name", "") - if name.startswith(SOURCE_MATCH): - return name - return None + if name.startswith(SOURCE_MATCHES) and name not in names: + names.append(name) + return names -class AudioManager: - """Keeps the Wave XLR capture stream active via a watched pw-cat subprocess. +def _aggregate(states): + """One (present, healthy, state) for many pins. - The subprocess's stdout is drained by a reader thread; the main loop - detects wedge ("alive but no data") and recycles the subprocess. + The worst pin wins the state — a wedged device must not hide behind a + healthy one — and the manager is healthy only when every pin is. """ - - def __init__(self, on_status_change=None): - self._running = False - self._loop_thread = None - self._cat_proc = None - self._reader_thread = None + if not states: + return False, False, "absent" + for worst in ("wedged", "silent"): + if worst in states: + return True, False, worst + return True, all(s == "ok" for s in states), "ok" + + +class _Pin: + """One watched pw-cat keepalive against one Wave capture node.""" + + def __init__(self, source_name): + self.source_name = source_name + self.state = "ok" + self._proc = None + self._reader = None self._last_data_at = 0.0 self._last_signal_at = 0.0 - self._source_name = None + self._started_at = 0.0 self._silence_recycles = 0 self._muted = False self._mute_checked_at = 0.0 - self._healthy = False - self._state = "absent" - self._device_present = False - self.on_status_change = on_status_change - @property - def healthy(self): - return self._healthy - - @property - def state(self): - """One of "ok", "wedged", "silent", "absent".""" - return self._state - - @property - def device_present(self): - return self._device_present + # --- lifecycle --- def start(self): - if self._running: - return - self._running = True - self._loop_thread = threading.Thread(target=self._run, daemon=True) - self._loop_thread.start() - - def stop(self): - self._running = False - self._kill_cat() - if self._loop_thread: - self._loop_thread.join(timeout=3) + self.kill() + now = time.monotonic() + self._last_data_at = now + self._last_signal_at = now + self._started_at = now + self._proc = subprocess.Popen( + [ + "pw-cat", "--record", + "--target", self.source_name, + "--channels", "1", + "--format", "s16", + "--rate", "48000", + "--latency", "200ms", + "-", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + # New process group so SIGKILL on the leader cleans up any + # children too. start_new_session=True is the portable spelling. + start_new_session=True, + ) + self._reader = threading.Thread( + target=self._drain, args=(self._proc,), daemon=True + ) + self._reader.start() + log.info( + f"Started capture keepalive for {self.source_name} " + f"(PID {self._proc.pid})" + ) - def _kill_cat(self): - proc = self._cat_proc - reader = self._reader_thread - self._cat_proc = None - self._reader_thread = None + def kill(self): + proc, reader = self._proc, self._reader + self._proc = None + self._reader = None if proc and proc.poll() is None: try: proc.terminate() @@ -217,40 +237,11 @@ def _kill_cat(self): proc.wait(timeout=1) except subprocess.TimeoutExpired: pass - log.info("Stopped capture keepalive") + log.info(f"Stopped capture keepalive for {self.source_name}") # Reader thread exits when the pipe closes. if reader and reader.is_alive(): reader.join(timeout=2) - def _start_cat(self, source_name): - """Spawn pw-cat with stdout piped so we can monitor byte flow.""" - self._kill_cat() - now = time.monotonic() - self._last_data_at = now - self._last_signal_at = now - self._source_name = source_name - self._cat_proc = subprocess.Popen( - [ - "pw-cat", "--record", - "--target", source_name, - "--channels", "1", - "--format", "s16", - "--rate", "48000", - "--latency", "200ms", - "-", - ], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - # New process group so SIGKILL on the leader cleans up any - # children too. start_new_session=True is the portable spelling. - start_new_session=True, - ) - self._reader_thread = threading.Thread( - target=self._drain, args=(self._cat_proc,), daemon=True - ) - self._reader_thread.start() - log.info(f"Started capture keepalive (PID {self._cat_proc.pid})") - def _drain(self, proc): """Drain pw-cat's stdout, updating the last-data-received timestamp. @@ -277,8 +268,10 @@ def _drain(self, proc): except Exception: pass - def _cat_alive(self): - return self._cat_proc is not None and self._cat_proc.poll() is None + # --- health --- + + def _alive(self): + return self._proc is not None and self._proc.poll() is None def _data_flowing(self): return (time.monotonic() - self._last_data_at) < WEDGE_TIMEOUT @@ -292,9 +285,109 @@ def _source_muted(self): if now - self._mute_checked_at < SILENCE_RECHECK: return self._muted self._mute_checked_at = now - self._muted = _source_is_muted(self._source_name) + self._muted = _source_is_muted(self.source_name) return self._muted + def step(self): + """Advance the watchdog one tick; returns the pin's state string.""" + if not self._alive(): + if self._proc is not None: + log.warning( + f"Capture keepalive for {self.source_name} exited " + f"unexpectedly (rc={self._proc.poll()}); restarting" + ) + self.start() + self.state = "ok" + return self.state + + if time.monotonic() - self._started_at < STARTUP_GRACE: + return self.state + + if not self._data_flowing(): + stalled_for = time.monotonic() - self._last_data_at + log.warning( + f"Capture keepalive for {self.source_name} wedged " + f"({stalled_for:.1f}s without data); recycling to release " + "the shared USB clock" + ) + self.kill() + # Brief settle so PipeWire fully releases the device before + # the next tick's restart reattaches. + self.state = "wedged" + return self.state + + if self._signal_flowing(): + self._silence_recycles = 0 + self.state = "ok" + return self.state + + if self._source_muted(): + # Zeros are the correct output for a muted mic. Move the clock + # along so an unmute is what starts the silence window, not the + # mute that preceded it. + self._last_signal_at = time.monotonic() + self.state = "ok" + return self.state + + silent_for = time.monotonic() - self._last_signal_at + if self._silence_recycles < MAX_SILENCE_RECYCLES: + self._silence_recycles += 1 + log.warning( + f"Capture stream for {self.source_name} silent " + f"({silent_for:.0f}s of zero samples while unmuted); " + "recycling once" + ) + self.kill() + self.state = "silent" + return self.state + + +class AudioManager: + """One watched keepalive per connected Wave device. + + Discovery reruns every tick, so a device plugged in later gets its pin + and an unplugged one loses it. Status is the aggregate: the worst pin's + state, healthy only when every pin is — two devices means two shared + USB clocks, either of which can wedge on its own. + """ + + def __init__(self, on_status_change=None): + self._running = False + self._loop_thread = None + self._pins = {} # source node name -> _Pin + self._healthy = False + self._state = "absent" + self._device_present = False + self.on_status_change = on_status_change + + @property + def healthy(self): + return self._healthy + + @property + def state(self): + """One of "ok", "wedged", "silent", "absent" — the worst pin's.""" + return self._state + + @property + def device_present(self): + return self._device_present + + def start(self): + if self._running: + return + self._running = True + self._loop_thread = threading.Thread(target=self._run, daemon=True) + self._loop_thread.start() + + def stop(self): + self._running = False + for pin in self._pins.values(): + pin.kill() + self._pins = {} + if self._loop_thread: + self._loop_thread.join(timeout=3) + def _update_status(self, present, healthy, state): changed = ( present != self._device_present @@ -310,69 +403,22 @@ def _update_status(self, present, healthy, state): def _run(self): while self._running: try: - if self._cat_alive(): - if not self._data_flowing(): - stalled_for = time.monotonic() - self._last_data_at - log.warning( - f"Capture keepalive wedged ({stalled_for:.1f}s " - "without data); recycling to release the shared " - "USB clock" - ) - self._kill_cat() - self._update_status(True, False, "wedged") - # Brief settle so PipeWire fully releases the device - # before the new pw-cat reattaches. - time.sleep(0.5) - continue - - if self._signal_flowing(): - self._silence_recycles = 0 - self._update_status(True, True, "ok") - time.sleep(WATCHDOG_INTERVAL) - continue - - if self._source_muted(): - # Zeros are the correct output for a muted mic. - # Move the clock along so an unmute is what starts - # the silence window, not the mute that preceded it. - self._last_signal_at = time.monotonic() - self._update_status(True, True, "ok") - time.sleep(WATCHDOG_INTERVAL) - continue - - silent_for = time.monotonic() - self._last_signal_at - if self._silence_recycles < MAX_SILENCE_RECYCLES: - self._silence_recycles += 1 - log.warning( - f"Capture stream silent ({silent_for:.0f}s of zero " - "samples while unmuted); recycling once" - ) - self._kill_cat() - self._update_status(True, False, "silent") - time.sleep(0.5) - continue - - self._update_status(True, False, "silent") - time.sleep(SILENCE_RECHECK) - continue - - if self._cat_proc is not None: - log.warning( - f"Capture keepalive exited unexpectedly " - f"(rc={self._cat_proc.poll()}); restarting" - ) - self._cat_proc = None - - source_name = _get_source_node_name() - if not source_name: - self._update_status(False, False, "absent") - time.sleep(5) - continue - - self._start_cat(source_name) - time.sleep(STARTUP_GRACE) - started = self._cat_alive() and self._data_flowing() - self._update_status(True, started, "ok" if started else "wedged") + names = _get_source_node_names() + + for name in list(self._pins): + if name not in names: + log.info(f"Wave source {name} gone; dropping its pin") + self._pins.pop(name).kill() + + for name in names: + if name not in self._pins: + pin = _Pin(name) + pin.start() + self._pins[name] = pin + + states = [pin.step() for pin in self._pins.values()] + self._update_status(*_aggregate(states)) + time.sleep(WATCHDOG_INTERVAL if states else 5) except Exception as e: log.error(f"Audio manager error: {e}") diff --git a/wavexlr/device.py b/wavexlr/device.py index 0f2b2b5..761fa96 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -43,10 +43,89 @@ ] _lib.libusb_control_transfer.restype = ctypes.c_int +_lib.libusb_get_device_list.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))] +_lib.libusb_get_device_list.restype = ctypes.c_ssize_t +_lib.libusb_free_device_list.argtypes = [ + ctypes.POINTER(ctypes.c_void_p), ctypes.c_int] +_lib.libusb_free_device_list.restype = None +_lib.libusb_get_bus_number.argtypes = [ctypes.c_void_p] +_lib.libusb_get_bus_number.restype = ctypes.c_uint8 +_lib.libusb_get_device_address.argtypes = [ctypes.c_void_p] +_lib.libusb_get_device_address.restype = ctypes.c_uint8 +_lib.libusb_open.argtypes = [ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p)] +_lib.libusb_open.restype = ctypes.c_int + + +class _DeviceDescriptor(ctypes.Structure): + _fields_ = [ + ("bLength", ctypes.c_uint8), + ("bDescriptorType", ctypes.c_uint8), + ("bcdUSB", ctypes.c_uint16), + ("bDeviceClass", ctypes.c_uint8), + ("bDeviceSubClass", ctypes.c_uint8), + ("bDeviceProtocol", ctypes.c_uint8), + ("bMaxPacketSize0", ctypes.c_uint8), + ("idVendor", ctypes.c_uint16), + ("idProduct", ctypes.c_uint16), + ("bcdDevice", ctypes.c_uint16), + ("iManufacturer", ctypes.c_uint8), + ("iProduct", ctypes.c_uint8), + ("iSerialNumber", ctypes.c_uint8), + ("bNumConfigurations", ctypes.c_uint8), + ] + + +_lib.libusb_get_device_descriptor.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(_DeviceDescriptor)] +_lib.libusb_get_device_descriptor.restype = ctypes.c_int + _ctx = ctypes.c_void_p() _lib.libusb_init(ctypes.byref(_ctx)) +def _each_usb_device(visit): + """Call visit(vid, pid, bus, addr, dev_ptr) for every device on the bus. + + The device list is freed before returning, so visit must open (ref) a + device it wants to keep, not stash the pointer. + """ + devs = ctypes.POINTER(ctypes.c_void_p)() + count = _lib.libusb_get_device_list(_ctx, ctypes.byref(devs)) + if count < 0: + return + try: + desc = _DeviceDescriptor() + for i in range(count): + dev = devs[i] + if _lib.libusb_get_device_descriptor(dev, ctypes.byref(desc)) != 0: + continue + visit(desc.idVendor, desc.idProduct, + _lib.libusb_get_bus_number(dev), + _lib.libusb_get_device_address(dev), dev) + finally: + _lib.libusb_free_device_list(devs, 1) + + +def scan(): + """Every supported Wave on the bus: [(profile, bus, addr)], bus order. + + connect() opens only the first device of a vid:pid, which made a second + identical model invisible; this is how a caller sees them all. + """ + by_id = {(p.vid, p.pid): p for p in PROFILES} + found = [] + + def visit(vid, pid, bus, addr, _dev): + profile = by_id.get((vid, pid)) + if profile is not None: + found.append((profile, bus, addr)) + + _each_usb_device(visit) + return sorted(found, key=lambda e: (e[1], e[2])) + + def _find_card(matches, vid=None, pid=None, usbbus=None): """ALSA card number for a device. @@ -127,6 +206,39 @@ def _alsa_get(card): return state +def present_units(): + """{(vid, pid, "bus/addr")} for every supported Wave on the bus. + + Sysfs only — no USB permissions, no enumeration — cheap enough for a + periodic tick. The bus/addr string matches WaveDevice.usbbus, so the + caller can diff this against what it holds open and notice a unit + appearing or vanishing while others stay connected. + """ + wanted = {(f"{p.vid:04x}", f"{p.pid:04x}") for p in PROFILES} + base = "/sys/bus/usb/devices" + units = set() + try: + entries = os.listdir(base) + except OSError: + return units + for entry in entries: + try: + with open(os.path.join(base, entry, "idVendor")) as f: + vid = f.read().strip() + with open(os.path.join(base, entry, "idProduct")) as f: + pid = f.read().strip() + if (vid, pid) not in wanted: + continue + with open(os.path.join(base, entry, "busnum")) as f: + bus = int(f.read().strip()) + with open(os.path.join(base, entry, "devnum")) as f: + addr = int(f.read().strip()) + except (OSError, ValueError): + continue + units.add((vid, pid, f"{bus:03d}/{addr:03d}")) + return units + + def wave_present(): """True when any supported Wave is on the USB bus. Sysfs only -- no USB permissions, no enumeration, cheap enough for a 2 s reconnect tick.""" @@ -269,34 +381,88 @@ def __init__(self): self._card = None self._last_fw = None # last known firmware state for change detection self.profile = None + self.usbbus = None # "bus/addr" when opened via scan() + self.info = {} # devinfo cache: fw/api/serial, filled by caller + self._alsa_tick = 0 # decimates the amixer reads inside get_all() @property def connected(self): return self._handle is not None - def connect(self): - for profile in PROFILES: - handle = _lib.libusb_open_device_with_vid_pid(_ctx, profile.vid, profile.pid) + def connect(self, profile=None, bus=None, addr=None): + """Open a Wave. With no arguments: the first supported device found. + + With (profile, bus, addr) from scan(): that specific unit — which is + what lets two devices, even of the same model, each get their own + handle. bus/addr also pin the ALSA card via /proc/asound usbbus, so + two of one model cannot end up sharing a card either. + """ + if profile is not None and bus is not None: + handle = self._open_at(profile, bus, addr) if handle: self._handle = handle self.profile = profile + self.usbbus = f"{bus:03d}/{addr:03d}" self._card = _find_card( - profile.card_match, vid=profile.vid, pid=profile.pid, - ) + profile.card_match, vid=profile.vid, pid=profile.pid, + usbbus=self.usbbus, + ) + return + raise RuntimeError( + f"Could not open {profile.display_name} at {bus:03d}/{addr:03d}") + + for prof in PROFILES: + handle = _lib.libusb_open_device_with_vid_pid( + _ctx, prof.vid, prof.pid) + if handle: + self._handle = handle + self.profile = prof + self._card = _find_card( + prof.card_match, vid=prof.vid, pid=prof.pid, + ) return raise RuntimeError("No supported Elgato Wave device found") + @staticmethod + def _open_at(profile, bus, addr): + """A handle for the unit at (bus, addr), or None.""" + handle = ctypes.c_void_p() + + def visit(vid, pid, dbus, daddr, dev): + if handle.value: + return + if (vid, pid) == (profile.vid, profile.pid) \ + and (dbus, daddr) == (bus, addr): + opened = ctypes.c_void_p() + if _lib.libusb_open(dev, ctypes.byref(opened)) == 0: + handle.value = opened.value + + _each_usb_device(visit) + return handle.value and handle + def disconnect(self): - if self._handle: - _lib.libusb_close(self._handle) - self._handle = None + # Under the transfer lock: closing a handle another thread is mid- + # control-transfer on is a use-after-free inside libusb. The poll + # worker and the device watch both touch devices concurrently now, + # so the close must wait its turn like any other USB operation. + with self._lock: + if self._handle: + _lib.libusb_close(self._handle) + self._handle = None self._card = None self._last_fw = None + self.usbbus = None def _ctrl_read(self, wValue, length): """USB control read — no detach needed.""" buf = (ctypes.c_ubyte * length)() with self._lock: + # Checked INSIDE the lock: a multi-transfer operation releases it + # between transfers, and a disconnect (unplug handling) can slot + # in there. libusb does not NULL-check the handle — passing the + # cleared one was a hard SEGV, not an error return. + if self._handle is None: + raise RuntimeError("device disconnected") ret = _lib.libusb_control_transfer( self._handle, RT_CLASS_IN, BREQUEST_READ, wValue, self.profile.windex, buf, length, 1000, @@ -310,6 +476,8 @@ def _ctrl_write(self, wValue, data): data = bytes(data) buf = (ctypes.c_ubyte * len(data))(*data) with self._lock: + if self._handle is None: + raise RuntimeError("device disconnected") ret = _lib.libusb_control_transfer( self._handle, RT_CLASS_OUT, BREQUEST_WRITE, wValue, self.profile.windex, buf, len(data), 1000, @@ -387,7 +555,15 @@ def get_all(self): # Sync firmware ↔ ALSA if self._card: - alsa = _alsa_get(self._card) + # The firmware→ALSA direction below costs nothing while nothing + # changed, but reading ALSA back is two amixer subprocesses per + # call — at 10 Hz across two devices that was forty forks a + # second for values that almost never move. Read every 5th poll + # (0.5 s): pavucontrol moving the mic is still picked up + # promptly, and the physical controls keep their 10 Hz path. + self._alsa_tick = (self._alsa_tick + 1) % 5 + read_alsa = self._alsa_tick == 0 or self._last_fw is None + alsa = _alsa_get(self._card) if read_alsa else {} dirty = False # whether we need to write config back if self._last_fw is not None: From 916bed51357ec21573b309d950b69fa328df6a7d Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:53 -0500 Subject: [PATCH 72/99] Bundle everything a bug report needs into one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_diag.py | 51 +++++++++ wavexlr/diag.py | 273 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 tests/test_diag.py create mode 100644 wavexlr/diag.py diff --git a/tests/test_diag.py b/tests/test_diag.py new file mode 100644 index 0000000..c958b74 --- /dev/null +++ b/tests/test_diag.py @@ -0,0 +1,51 @@ +"""The diagnostics bundle must survive everything being wrong. + +It is what a reporter attaches when something is broken, so a collector +that is missing, hung or crashing must become a line in the bundle, never +an exception that prevents the bundle. +""" + +import unittest + +from wavexlr import diag + + +class Assemble(unittest.TestCase): + def test_a_raising_collector_becomes_a_line(self): + def boom(): + raise RuntimeError("kaput") + text = diag.assemble(sections=(("Broken", boom),)) + self.assertIn("== Broken ==", text) + self.assertIn("unavailable", text) + self.assertIn("kaput", text) + + def test_other_sections_survive_a_broken_one(self): + def boom(): + raise OSError("no") + text = diag.assemble(sections=(("Bad", boom), + ("Good", lambda: "fine"))) + self.assertIn("fine", text) + + def test_every_real_section_appears(self): + text = diag.assemble() + for title, _fn in diag.SECTIONS: + self.assertIn(f"== {title} ==", text) + + def test_full_flag_is_announced_in_the_header(self): + self.assertIn("--full", diag.assemble(full=True).splitlines()[0]) + self.assertNotIn("--full", diag.assemble(full=False).splitlines()[0]) + + def test_default_withholds_config_contents(self): + self.assertIn("contents withheld", diag.assemble(full=False)) + + +class Run(unittest.TestCase): + def test_a_missing_command_is_a_note(self): + self.assertIn("not found", diag._run("no-such-command-here")) + + def test_a_failing_command_is_a_note(self): + self.assertIn("exit", diag._run("false")) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/diag.py b/wavexlr/diag.py new file mode 100644 index 0000000..6d703b5 --- /dev/null +++ b/wavexlr/diag.py @@ -0,0 +1,273 @@ +"""Diagnostics export: everything a bug report needs, in one text file. + + python3 -m wavexlr.diag [--full] [-o FILE] + +Every section is collected in isolation: a source that is missing, hung or +broken produces a line saying so, never a traceback and never a hang — the +bundle a reporter attaches when something is wrong must survive everything +being wrong. + +Privacy: the default bundle carries no config contents and no stream lists; +running application names are personal. --full includes them, and says so in +the header. Device serials stay — they are how hardware reports are matched. + +The firmware serves vendor transfers to one process at a time, so the device +section reads through a fresh handle only when OpenWave is closed; run the +in-app export otherwise. +""" + +import argparse +import glob +import json +import os +import subprocess +import sys +import time +import traceback + +TIMEOUT = 3 + + +def _run(*argv): + """stdout of a command, or a one-line failure note.""" + try: + r = subprocess.run(argv, capture_output=True, text=True, + timeout=TIMEOUT) + except FileNotFoundError: + return f"({argv[0]}: not found)" + except subprocess.TimeoutExpired: + return f"({argv[0]}: timed out after {TIMEOUT}s)" + out = r.stdout.strip() + if r.returncode != 0: + err = (r.stderr or "").strip().splitlines() + return f"({argv[0]}: exit {r.returncode}{': ' + err[0] if err else ''})" + return out + + +def _hexdump(data): + lines = [] + for i in range(0, len(data), 16): + chunk = data[i:i + 16] + hexs = " ".join(f"{b:02x}" for b in chunk) + text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f" {i:04x} {hexs:<47} {text}") + return "\n".join(lines) + + +# --- Collectors. Each returns a string; assemble() isolates failures. --- + + +def collect_versions(): + lines = [f"python: {sys.version.split()[0]} ({sys.executable})"] + try: + with open("/etc/os-release") as f: + for line in f: + if line.startswith("PRETTY_NAME="): + lines.append("distro: " + line.split("=", 1)[1].strip().strip('"')) + except OSError: + lines.append("distro: unknown (/etc/os-release unreadable)") + lines.append("pipewire: " + _run("pipewire", "--version").splitlines()[-1]) + lines.append("wireplumber: " + _run("wireplumber", "--version").splitlines()[-1]) + return "\n".join(lines) + + +def collect_usb(): + """Which supported devices sysfs sees — no USB permissions needed.""" + from .profiles import PROFILES + present = {} + for entry in glob.glob("/sys/bus/usb/devices/*"): + try: + with open(os.path.join(entry, "idVendor")) as f: + vid = f.read().strip() + with open(os.path.join(entry, "idProduct")) as f: + pid = f.read().strip() + except OSError: + continue + present[(vid, pid)] = True + lines = [] + for p in PROFILES: + seen = (f"{p.vid:04x}", f"{p.pid:04x}") in present + lines.append(f"{p.display_name} ({p.vid:04x}:{p.pid:04x}): " + + ("present" if seen else "absent")) + return "\n".join(lines) + + +def describe_device(dev): + """Profile, devinfo and a config hexdump for one open device.""" + p = dev.profile + lines = [f"profile: {p.display_name} ({p.vid:04x}:{p.pid:04x})" + + (f" at {dev.usbbus}" if dev.usbbus else ""), + f"alsa card: {dev._card}"] + try: + info = dev.read_device_info() + lines.append(f"firmware: {info['fw_version']} " + f"api: {info['api_version']} serial: {info['serial']}") + except Exception as e: + lines.append(f"devinfo: unreadable ({e})") + try: + lines.append(f"config ({p.config_len} bytes expected):") + lines.append(_hexdump(dev.read_config())) + except Exception as e: + lines.append(f"config: unreadable ({e})") + return lines + + +def collect_device(): + """Every supported device, each through a fresh handle in turn.""" + from .device import WaveDevice, scan + units = scan() + if not units: + return "no supported device on the bus" + lines = [] + for profile, bus, addr in units: + dev = WaveDevice() + try: + dev.connect(profile, bus, addr) + except RuntimeError as e: + lines.append(f"{profile.display_name} at {bus:03d}/{addr:03d}: " + f"could not open ({e})") + continue + try: + lines.extend(describe_device(dev)) + finally: + dev.disconnect() + lines.append("") + if any("unreadable" in line or "could not open" in line for line in lines): + lines.append("(a device was seen but reads failed: OpenWave is " + "probably running and holds the one handle the " + "firmware serves — use the in-app export, or quit " + "OpenWave including the tray icon)") + return "\n".join(lines).rstrip() + + +def collect_udev(): + from . import setup + lines = [f"udev rules complete: {setup.udev_installed()}"] + for path in (setup.UDEV_PATH, setup.UDEV_PATH_OLD): + lines.append(f"{path}: " + + ("present" if os.path.exists(path) else "absent")) + return "\n".join(lines) + + +def collect_service(): + from . import service + return "\n".join([ + f"backend: {service.backend_name}", + f"installed: {service.is_installed()}", + f"running: {service.is_running()}", + f"failed: {service.is_failed()}", + ]) + + +def collect_journal(): + from . import service + if service.backend_name != "systemd": + return f"(journal only collected on systemd; backend is {service.backend_name})" + return _run("journalctl", "--user", "-u", "openwave", + "-n", "100", "--no-pager") + + +def collect_pipewire(full=False): + out = _run("pw-dump") + if out.startswith("("): + return out + try: + objects = json.loads(out) + except ValueError as e: + return f"(pw-dump output unparseable: {e})" + lines = [] + for obj in objects: + props = (obj.get("info") or {}).get("props") or {} + name = props.get("node.name", "") + if not name: + continue + ours = name.startswith("openwave_") or "Elgato" in name \ + or "Wave" in props.get("node.description", "") + if not (ours or full): + continue + state = (obj.get("info") or {}).get("state", "?") + lines.append(f"{name} [{state}] {props.get('node.description', '')}") + header = "all nodes:" if full else "openwave / Elgato nodes:" + return header + "\n" + ("\n".join(lines) or "(none)") + + +def collect_configs(full=False): + from . import mixer, mixes, sources + lines = [] + paths = { + "sources.json": sources.CONFIG_PATH, + "mixdefs.json": mixes.CONFIG_PATH, + "mixes.json": mixer.CONFIG_PATH, + "ui-state.json": os.path.expanduser("~/.config/openwave/ui-state.json"), + } + for name, path in paths.items(): + if not os.path.exists(path): + lines.append(f"{name}: absent") + continue + size = os.path.getsize(path) + try: + with open(path) as f: + body = f.read() + json.loads(body) + state = "parses" + except (OSError, ValueError) as e: + body, state = None, f"BROKEN ({e})" + lines.append(f"{name}: {size} bytes, {state}") + if full and body is not None: + lines.append(body.rstrip()) + if not full: + lines.append("(contents withheld — app names are personal; --full includes them)") + return "\n".join(lines) + + +SECTIONS = ( + ("Versions", collect_versions), + ("USB devices", collect_usb), + ("Device", collect_device), + ("udev", collect_udev), + ("Service", collect_service), + ("Journal", collect_journal), + ("PipeWire", collect_pipewire), + ("Config files", collect_configs), +) + + +def assemble(full=False, sections=SECTIONS): + """The whole bundle as a string. No collector failure escapes.""" + stamp = time.strftime("%Y-%m-%d %H:%M:%S %z") + out = [f"OpenWave diagnostics — {stamp}" + + (" (--full: includes config contents and all node names)" + if full else "")] + for title, collect in sections: + out.append(f"\n== {title} ==") + try: + if collect in (collect_pipewire, collect_configs): + out.append(collect(full=full)) + else: + out.append(collect()) + except Exception: + last = traceback.format_exc().strip().splitlines()[-1] + out.append(f"unavailable ({last})") + return "\n".join(out) + "\n" + + +def default_path(): + return os.path.abspath( + time.strftime("openwave-diag-%Y%m%d-%H%M%S.txt")) + + +def main(): + parser = argparse.ArgumentParser(prog="python3 -m wavexlr.diag") + parser.add_argument("--full", action="store_true", + help="include config contents and all node names") + parser.add_argument("-o", "--output", default=None, + help="write here instead of ./openwave-diag-.txt") + args = parser.parse_args() + path = args.output or default_path() + with open(path, "w") as f: + f.write(assemble(full=args.full)) + print(path) + + +if __name__ == "__main__": + main() From f9500eda717edcc3d14a4de40e714d1d26ecc7be Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:19:54 -0500 Subject: [PATCH 73/99] Let a named scene recall every level as one gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/__init__.py | 3 +- tests/test_scenes.py | 186 +++++++++++++++++++++++++++++++++++++++++++ wavexlr/mixer.py | 92 +++++++++++++++++++++ wavexlr/scenes.py | 117 +++++++++++++++++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 tests/test_scenes.py create mode 100644 wavexlr/scenes.py diff --git a/tests/__init__.py b/tests/__init__.py index b247bf1..8eb0c8f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -16,7 +16,7 @@ import os import tempfile -from wavexlr import mixer, mixes, sources +from wavexlr import mixer, mixes, scenes, sources _SANDBOX = tempfile.TemporaryDirectory(prefix="openwave-tests-") atexit.register(_SANDBOX.cleanup) @@ -24,4 +24,5 @@ sources.CONFIG_PATH = os.path.join(_SANDBOX.name, "sources.json") mixes.CONFIG_PATH = os.path.join(_SANDBOX.name, "mixdefs.json") mixer.CONFIG_PATH = os.path.join(_SANDBOX.name, "mixes.json") +scenes.CONFIG_PATH = os.path.join(_SANDBOX.name, "scenes.json") mixer.Mixer._TRACE_PATH = os.path.join(_SANDBOX.name, "write-trace.log") diff --git a/tests/test_scenes.py b/tests/test_scenes.py new file mode 100644 index 0000000..5ad817e --- /dev/null +++ b/tests/test_scenes.py @@ -0,0 +1,186 @@ +"""Scenes: named level snapshots, captured live and applied partially. + +A scene sets levels on the matrix that exists. It never restructures it, +and a scene naming things that are gone applies what still matches and +reports the rest — recalling an old scene must never be dangerous. +""" + +import json +import os +import unittest + +from wavexlr import scenes +from .support import FakePipeWire, bare_mixer, temp_config + +SOURCES = { + "dock": {"id": "dock", "name": "XLR Dock", "level": 0.8, "muted": False}, + "music": {"id": "music", "name": "Music", "level": 0.5, "muted": True}, +} +MIXES = { + "personal": {"id": "personal", "name": "Personal Mix", + "sink": "openwave_personal_mix"}, + "chat": {"id": "chat", "name": "Chat Mix", "sink": "openwave_chat_mix"}, +} + + +class Store(unittest.TestCase): + def setUp(self): + try: + os.remove(scenes.CONFIG_PATH) + except OSError: + pass + + def test_empty_on_first_run(self): + self.assertEqual(scenes.load(), {}) + + def test_round_trip(self): + sid = scenes.put("Streaming", {"cells": {"dock.personal": + {"volume": 1.0}}}) + self.assertEqual(sid, "streaming") + loaded = scenes.load() + self.assertEqual(loaded[sid]["name"], "Streaming") + self.assertIn("dock.personal", loaded[sid]["cells"]) + + def test_saving_again_replaces(self): + scenes.put("Streaming", {"volumes": {"personal": {"volume": 0.2}}}) + scenes.put("Streaming", {"volumes": {"personal": {"volume": 0.9}}}) + loaded = scenes.load() + self.assertEqual(len(loaded), 1) + self.assertEqual(loaded["streaming"]["volumes"]["personal"]["volume"], + 0.9) + + def test_remove(self): + scenes.put("Late Night", {}) + self.assertTrue(scenes.remove("late-night")) + self.assertFalse(scenes.remove("late-night")) + self.assertEqual(scenes.load(), {}) + + def test_a_corrupt_store_is_set_aside_not_fatal(self): + with open(scenes.CONFIG_PATH, "w") as f: + f.write("{not json") + self.assertEqual(scenes.load(), {}) + self.assertTrue(os.path.exists(scenes.CONFIG_PATH + ".corrupt")) + os.remove(scenes.CONFIG_PATH + ".corrupt") + + def test_ids_are_slugs(self): + self.assertEqual(scenes.scene_id("Late Night! Stream #2"), + "late-night-stream-2") + self.assertEqual(scenes.scene_id("???"), "scene") + + +class HardwareKeying(unittest.TestCase): + """Two devices of one model must not share a scene entry.""" + + HW = { + "wave_xlr_mk2:AAAA": {"gain_raw": 100}, + "wave_xlr_mk2:BBBB": {"gain_raw": 200}, + "wave3": {"gain_raw": 300}, # pre-serial scene + } + + def test_exact_serial_wins(self): + self.assertEqual( + scenes.pick_hardware_entry(self.HW, "wave_xlr_mk2", "BBBB"), + {"gain_raw": 200}) + + def test_legacy_bare_profile_key_still_applies(self): + self.assertEqual( + scenes.pick_hardware_entry(self.HW, "wave3", "CCCC"), + {"gain_raw": 300}) + + def test_a_replacement_unit_inherits_the_model_entry(self): + entry = scenes.pick_hardware_entry(self.HW, "wave_xlr_mk2", "NEW1") + self.assertIn(entry, ({"gain_raw": 100}, {"gain_raw": 200})) + + def test_a_different_model_gets_nothing(self): + self.assertIsNone( + scenes.pick_hardware_entry(self.HW, "wave_xlr", "AAAA")) + self.assertIsNone(scenes.pick_hardware_entry({}, "wave3", "X")) + + def test_key_shape(self): + self.assertEqual(scenes.hardware_key("wave3", "S1"), "wave3:S1") + self.assertEqual(scenes.hardware_key("wave3", ""), "wave3") + + +class MixerScenes(unittest.TestCase): + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self.addCleanup(self._ctx.__exit__, None, None, None) + self.pw = FakePipeWire() + self.mx = bare_mixer( + _pw=self.pw, + _sources={k: dict(v) for k, v in SOURCES.items()}, + _mixes={k: dict(v) for k, v in MIXES.items()}, + ) + + def test_capture_reads_live_state(self): + self.mx.set_cell("dock", "personal", 0.7, False) + self.mx.remember_mix_volume("personal", 0.65, False) + state = self.mx.scene_state() + self.assertEqual(state["sources"]["dock"], + {"level": 0.8, "muted": False}) + self.assertEqual(state["cells"]["dock.personal"]["volume"], 0.7) + self.assertEqual(state["volumes"]["personal"]["volume"], 0.65) + self.assertIn("personal", state["outputs"]) + + def test_capture_and_apply_round_trip(self): + self.mx.set_cell("dock", "personal", 0.7, False) + self.mx.set_cell("music", "chat", 0.3, True) + state = self.mx.scene_state() + # Move everything, then recall. + self.mx.set_cell("dock", "personal", 0.1, True) + self.mx._sources["dock"]["level"] = 0.2 + skipped = self.mx.apply_scene(state) + self.assertEqual(skipped, []) + self.assertEqual(self.mx.get_cell("dock", "personal"), + {"volume": 0.7, "muted": False}) + self.assertEqual(self.mx._sources["dock"]["level"], 0.8) + + def test_gone_entries_are_skipped_and_reported(self): + scene = { + "sources": {"gone": {"level": 1.0}}, + "cells": {"gone.personal": {"volume": 1.0}, + "dock.gone_mix": {"volume": 1.0}}, + "outputs": {"gone_mix": "some_sink"}, + "volumes": {"gone_mix": {"volume": 0.5}}, + } + skipped = self.mx.apply_scene(scene) + self.assertEqual(sorted(skipped), + ["cell dock.gone_mix", "cell gone.personal", + "output gone_mix", "source gone", + "volume gone_mix"]) + # Nothing was created for them. + self.assertNotIn("gone", self.mx._sources) + self.assertNotIn("gone.personal", self.mx.cells()) + + def test_volumes_hit_the_sink_and_are_remembered(self): + self.mx.apply_scene( + {"volumes": {"personal": {"volume": 0.4, "muted": True}}}) + self.assertIn(("set_sink_volume", "openwave_personal_mix", 0.4), + self.pw.calls) + self.assertIn(("set_sink_mute", "openwave_personal_mix", True), + self.pw.calls) + self.assertEqual(self.mx.mix_volume("personal"), (0.4, True)) + + def test_ui_master_write_hits_sink_and_store(self): + """The header slider and an external mover must be indistinguishable + downstream: volume onto the sink, value into the store.""" + self.mx.set_mix_volume("personal", 0.55) + self.assertIn(("set_sink_volume", "openwave_personal_mix", 0.55), + self.pw.calls) + self.assertEqual(self.mx.mix_volume("personal"), (0.55, False)) + + def test_ui_master_write_preserves_remembered_mute(self): + self.mx.remember_mix_volume("personal", 0.9, True) + self.mx.set_mix_volume("personal", 0.4) + self.assertEqual(self.mx.mix_volume("personal"), (0.4, True)) + + def test_apply_reconciles_through_the_normal_paths(self): + """set_cell must be the entry point, so send × trim stays law.""" + self.mx.apply_scene({"cells": {"dock.personal": {"volume": 0.7}}}) + with self.mx._pending_lock: + self.assertIn(("cell", "dock", "personal"), self.mx._pending) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index d7e7cef..00c642c 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1238,6 +1238,98 @@ def _volumes(self): self._state[VOLUMES_STATE_KEY] = volumes return volumes + def scene_state(self): + """Everything a scene snapshots, read from live state. + + Live rather than stored on principle (the mix-master rule): whatever + moved a level — this window, a Stream Deck, pavucontrol — the value it + left is the one the scene should hold. + """ + with self._lock: + sources = { + sid: { + "level": float(s.get("level", 1.0)), + "muted": bool(s.get("muted", False)), + } + for sid, s in self._sources.items() + } + mix_ids = list(self._mixes) + state = { + "sources": sources, + "cells": {k: dict(v) for k, v in self.cells().items()}, + "outputs": {mid: self.get_output(mid) for mid in mix_ids}, + "volumes": {}, + } + for mid in mix_ids: + remembered = self.mix_volume(mid) + if remembered is not None: + volume, muted = remembered + state["volumes"][mid] = {"volume": volume, "muted": muted} + return state + + def apply_scene(self, scene): + """Set the matrix to a scene's levels. Returns what was skipped. + + Partial apply is normal, not an error: a scene naming a source or mix + that no longer exists sets what still matches and reports the rest. + Nothing is created or deleted — a scene is levels, not structure. + """ + skipped = [] + with self._lock: + known_sources = set(self._sources) + known_mixes = set(self._mixes) + sinks = {mid: m.get("sink") for mid, m in self._mixes.items()} + + for sid, entry in (scene.get("sources") or {}).items(): + if sid not in known_sources: + skipped.append(f"source {sid}") + continue + self.set_source_level( + sid, entry.get("level", 1.0), entry.get("muted", False)) + + for key, cell in (scene.get("cells") or {}).items(): + sid, _, mid = key.rpartition(".") + if sid not in known_sources or mid not in known_mixes: + skipped.append(f"cell {key}") + continue + self.set_cell(sid, mid, cell.get("volume", 0.0), + cell.get("muted", False)) + + for mid, choice in (scene.get("outputs") or {}).items(): + if mid not in known_mixes: + skipped.append(f"output {mid}") + continue + self.set_output(mid, choice) + + for mid, entry in (scene.get("volumes") or {}).items(): + sink = sinks.get(mid) + if mid not in known_mixes or not sink: + skipped.append(f"volume {mid}") + continue + volume = max(0.0, min(1.0, float(entry.get("volume", 1.0)))) + muted = bool(entry.get("muted", False)) + self._pw.set_sink_volume(sink, volume) + self._pw.set_sink_mute(sink, muted) + self.remember_mix_volume(mid, volume, muted) + return skipped + + def set_mix_volume(self, mix_id, volume): + """Set a mix master from the UI: the sink volume, remembered. + + The same pair of writes an external mover triggers implicitly — + volume onto the sink, value into the store — so a slider in the + header and a media key are indistinguishable downstream. + """ + with self._lock: + sink = (self._mixes.get(mix_id) or {}).get("sink") + if not sink: + return + volume = max(0.0, min(1.0, float(volume))) + remembered = self.mix_volume(mix_id) + muted = remembered[1] if remembered else False + self._pw.set_sink_volume(sink, volume) + self.remember_mix_volume(mix_id, volume, muted) + def mix_volume(self, mix_id): """The remembered (volume, muted) for a mix, or None if unseen.""" with self._lock: diff --git a/wavexlr/scenes.py b/wavexlr/scenes.py new file mode 100644 index 0000000..eadb454 --- /dev/null +++ b/wavexlr/scenes.py @@ -0,0 +1,117 @@ +"""The scene store: named snapshots of the matrix, recalled as one gesture. + +A scene holds levels for the matrix that exists — source trims and mutes, +cell sends and mutes, per-mix outputs and master volumes, and optionally +hardware state keyed by device profile. It deliberately does not hold mix or +source *definitions*: applying a scene never creates or deletes a row or a +column, so a scene can never restructure the matrix under the user. + +Not named "profiles": that word is taken by the per-device protocol +profiles in profiles.py, and a store that could be confused with USB +constants would be worse than a second noun. The UI may still say what it +likes. + +Store shape (~/.config/openwave/scenes.json): + + {"scenes": {"": {"name": ..., "sources": ..., "cells": ..., + "outputs": ..., "volumes": ..., "hardware": ...}}} + +Same durability rules as the other stores: whole-file rewrite on save, and +a corrupt file is preserved as scenes.json.corrupt and replaced with an +empty store — a bad write costs the scenes, never the app. +""" + +import json +import os +import re + +CONFIG_PATH = os.path.expanduser("~/.config/openwave/scenes.json") + + +class Unreadable(Exception): + pass + + +def _load_raw(): + with open(CONFIG_PATH) as f: + data = json.load(f) + if not isinstance(data, dict) or not isinstance(data.get("scenes"), dict): + raise Unreadable("top-level shape is not {'scenes': {...}}") + return data["scenes"] + + +def load(): + """Every stored scene, {} on first run; a corrupt file is set aside.""" + if not os.path.exists(CONFIG_PATH): + return {} + try: + return _load_raw() + except (OSError, ValueError, Unreadable): + try: + os.replace(CONFIG_PATH, CONFIG_PATH + ".corrupt") + except OSError: + pass + return {} + + +def save(scenes): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + tmp = CONFIG_PATH + ".tmp" + with open(tmp, "w") as f: + json.dump({"scenes": scenes}, f, indent=2) + os.replace(tmp, CONFIG_PATH) + + +def scene_id(name): + """A stable id from a human name: lowercase, dashes, nothing else.""" + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug or "scene" + + +def hardware_key(profile_key, serial): + """How a device is addressed inside a scene's hardware section. + + Keyed by serial, not by model: two Docks on one desk are different + devices with different gains, and a scene keyed by model alone could + only ever describe one of them. + """ + return f"{profile_key}:{serial}" if serial else profile_key + + +def pick_hardware_entry(hardware, profile_key, serial): + """The scene entry that should apply to this device, or None. + + Exact serial first; then a bare profile key (scenes saved before serial + keying); then any entry for the same model — a replaced unit should + still pick up the scene its predecessor was saved with, rather than + silently getting nothing. + """ + if not hardware: + return None + if serial: + exact = hardware.get(f"{profile_key}:{serial}") + if exact is not None: + return exact + if profile_key in hardware: + return hardware[profile_key] + for key, entry in hardware.items(): + if key.split(":", 1)[0] == profile_key: + return entry + return None + + +def put(name, payload): + """Store a scene under its name's id, replacing an existing one.""" + scenes = load() + sid = scene_id(name) + scenes[sid] = dict(payload, name=name) + save(scenes) + return sid + + +def remove(sid): + scenes = load() + if scenes.pop(sid, None) is not None: + save(scenes) + return True + return False From 7f1671d5945f7182ac06e0b80a4a305e6acc9750 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:21:02 -0500 Subject: [PATCH 74/99] Wire the window for many devices, scenes, meters and one shared mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- wavexlr/app.py | 742 +++++++++++++++++++++++++++++++++++++++++-- wavexlr/meter.py | 41 ++- wavexlr/mixmatrix.py | 122 ++++++- 3 files changed, 858 insertions(+), 47 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index 8a37cee..fde418d 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -23,7 +23,8 @@ from .sourcedialog import AddSourceDialog from . import (paths, setup, service, sources as sources_module, mixes as mixes_module, desktop as desktop_module, - recovery as recovery_module, device as device_module) + recovery as recovery_module, device as device_module, + diag as diag_module, scenes as scenes_module) logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") @@ -60,7 +61,10 @@ def __init__(self, **kwargs): # scrolls rather than being clipped. self.set_size_request(820, 480) self._restore_window_size() - self.dev = WaveDevice() + self.dev = WaveDevice() # the selected device; one of self._devs + self._devs = [] # every Wave held open, bus order + self._any_hw_muted = False + self._selector_updating = False self._gain_max = 0x5000 self._updating_ui = False self._last_state = None @@ -150,6 +154,12 @@ def _restore_window_size(self): if isinstance(width, int) and isinstance(height, int) \ and width >= 820 and height >= 480: self.set_default_size(width, height) + else: + # First run: without this the window opens at the 820x480 + # MINIMUM, which clips the matrix on every axis. Sized to show + # the seeded rows and three mix columns with room to breathe, + # while still fitting a 1366x768 laptop panel. + self.set_default_size(1280, 720) if state.get("maximized"): self.maximize() @@ -225,6 +235,20 @@ def _build_ui(self): self.service_btn.set_popover(service_pop) header.pack_start(self.service_btn) + # Scenes: named level snapshots, recalled as one gesture. + self.scene_btn = Gtk.MenuButton( + icon_name="camera-photo-symbolic", tooltip_text="Scenes", + ) + self.scene_btn.add_css_class("flat") + header.pack_start(self.scene_btn) + # Window-scoped so it does not join the app's remote surface: the + # dialog is menu plumbing, and org.gtk.Actions exports every app + # action whether meant for the bus or not. + save_as = Gio.SimpleAction.new("save-scene-as", None) + save_as.connect("activate", lambda *_a: self.prompt_save_scene()) + self.add_action(save_as) + self._rebuild_scene_menu() + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic", tooltip_text="Reconnect") refresh_btn.connect("clicked", lambda _: self._try_connect()) header.pack_end(refresh_btn) @@ -286,7 +310,9 @@ def _build_ui(self): name=source.get("name", source_id), icon_name=source.get("icon_name", "applications-multimedia-symbolic"), has_level=True, - removable=not sources_module.is_protected(source), + removable=(not sources_module.is_protected(source) + or sources_module.kind(source) + == sources_module.KIND_DEVICE), editable=True, reorderable=True, is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, @@ -303,6 +329,7 @@ def _build_ui(self): self.matrix.connect("rename-mix-clicked", self._on_rename_mix_clicked) self.matrix.connect("remove-mix-clicked", self._on_remove_mix_clicked) self.matrix.connect("mix-output-changed", self._on_mix_output_changed) + self.matrix.connect("mix-volume-changed", self._on_mix_volume_changed) # --- Sidebar: device controls ----------------------------------------- sidebar_scroll = Gtk.ScrolledWindow( @@ -324,6 +351,18 @@ def _build_ui(self): def _build_device_pane(self, parent): """Populate the sidebar: Microphone, Headphones, and device info.""" + # --- Device selector --- + # Hidden with a single device: a dropdown with one entry is a + # question with no answer. With two or more, the controls below + # bind to whichever unit is chosen here; every unit keeps polling + # and syncing regardless. + self._selector_group = Adw.PreferencesGroup(visible=False) + parent.append(self._selector_group) + self.device_combo = Adw.ComboRow(title="Device") + self.device_combo.connect("notify::selected", + self._on_device_selected) + self._selector_group.add(self.device_combo) + # --- Mic controls --- mic_group = Adw.PreferencesGroup(title="Microphone") parent.append(mic_group) @@ -474,6 +513,22 @@ def _build_device_pane(self, parent): self.serial_row.add_suffix(self.serial_label) info_expander.add_row(self.serial_row) + # --- Diagnostics --- + # In-app rather than CLI-only on purpose: the firmware serves vendor + # transfers to one process, and while the window is open that process + # is this one — the CLI cannot read the device the report is about. + diag_group = Adw.PreferencesGroup() + parent.append(diag_group) + diag_row = Adw.ActionRow( + title="Export diagnostics", + subtitle="One file to attach to a bug report", + activatable=True, + ) + diag_row.add_suffix(Gtk.Image.new_from_icon_name( + "document-save-symbolic")) + diag_row.connect("activated", self._on_export_diagnostics) + diag_group.add(diag_row) + def _on_autostart_toggled(self, row, _param): enabled, _hidden = desktop_module.set_autostart( row.get_active(), self.tray_row.get_active()) @@ -553,6 +608,47 @@ def _on_uninstall_response(self, dialog, result): err.add_response("ok", "OK") err.choose(self, None, lambda d, r: d.choose_finish(r)) + def _on_export_diagnostics(self, _row): + diag = diag_module + + def _device_here(): + """Every device section, through the handles this process holds.""" + if not self._devs: + return "no device connected" + lines = [] + for dev in self._devs: + lines.extend(diag.describe_device(dev)) + lines.append("") + return "\n".join(lines).rstrip() + + sections = tuple( + ("Device", _device_here) if title == "Device" else (title, fn) + for title, fn in diag.SECTIONS + ) + self._usb_async( + lambda: diag.assemble(sections=sections), + on_done=self._save_diagnostics, + ) + + def _save_diagnostics(self, text): + dialog = Gtk.FileDialog( + initial_name=os.path.basename(diag_module.default_path())) + + def _done(dlg, result): + try: + gfile = dlg.save_finish(result) + except GLib.Error: + return # dismissed + try: + with open(gfile.get_path(), "w") as f: + f.write(text) + except OSError as e: + err = Adw.AlertDialog(heading="Export Failed", body=str(e)) + err.add_response("ok", "OK") + err.choose(self, None, lambda d, r: d.choose_finish(r)) + + dialog.save(self, None, _done) + def _usb_async(self, fn, on_done=None, on_error=None): """Run fn in a background thread; call on_done/on_error on GTK thread.""" def _worker(): @@ -566,32 +662,131 @@ def _worker(): threading.Thread(target=_worker, daemon=True).start() def _try_connect(self): + # One connect at a time: the device watch, the poll-error path and + # the reconnect tick can all ask for one, and two workers reopening + # the same units concurrently would double-open and leak handles. + if getattr(self, "_connecting", False): + return + self._connecting = True self._window_title.set_subtitle("Connecting…") def _connect(): + # Remember which unit was selected before everything reopens, so + # a rescan (a second device plugged in) does not yank the sidebar + # off the device the user was adjusting. + prev = (getattr(self.dev.profile, "key", None), self.dev.usbbus) + for d in self._devs: + d.disconnect() self.dev.disconnect() - self.dev.connect() - info = {} + devs = [] + for profile, bus, addr in device_module.scan(): + d = WaveDevice() + try: + d.connect(profile, bus, addr) + except RuntimeError: + continue + try: + d.info = d.read_device_info() + except Exception: + d.info = {} + devs.append(d) try: - info = self.dev.read_device_info() + if not devs: + raise RuntimeError("No supported Elgato Wave device found") + selected = next( + (d for d in devs + if (d.profile.key, d.usbbus) == prev), devs[0]) + return {"devs": devs, "selected": selected, + "state": selected.get_all()} except Exception: - pass - return {"state": self.dev.get_all(), "info": info} + # A failure after handles opened (a device re-enumerating + # mid-connect, typically) must not strand them: an unclosed + # handle blocks every later open of that unit. + for d in devs: + d.disconnect() + raise def _done(result): + self._connecting = False + self._devs = result["devs"] + self.dev = result["selected"] # A Wave that appeared after the mixer was built: mic/hp were # resolved to None then, and only a re-detect corrects them. self.mixer.redetect_device() + self._refresh_device_selector() self._apply_profile(self.dev.profile) self._apply_state(result["state"]) - info = result["info"] - self.fw_label.set_label(info.get("fw_version", "—")) - self.api_label.set_label(info.get("api_version", "—")) - self.serial_label.set_label(info.get("serial", "—")) + self._apply_device_info() self._start_polling() + self._start_device_watch() def _fail(e): + self._connecting = False + self._devs = [] self._window_title.set_subtitle("Disconnected") + self._refresh_device_selector() self._start_reconnect() self._usb_async(_connect, _done, _fail) + def _device_label(self, dev): + """How a unit is named in the selector: model, plus enough serial + to tell two of the same model apart.""" + serial = (dev.info or {}).get("serial", "") + tail = serial[-4:] if serial else dev.usbbus or "?" + return f"{dev.profile.display_name} · {tail}" + + def _refresh_device_selector(self): + self._selector_updating = True + try: + names = Gtk.StringList() + for d in self._devs: + names.append(self._device_label(d)) + self.device_combo.set_model(names) + if self.dev in self._devs: + self.device_combo.set_selected(self._devs.index(self.dev)) + self._selector_group.set_visible(len(self._devs) > 1) + finally: + self._selector_updating = False + + def _on_device_selected(self, row, _param): + if self._selector_updating: + return + idx = row.get_selected() + if not (0 <= idx < len(self._devs)) or self._devs[idx] is self.dev: + return + self.dev = self._devs[idx] + self._last_state = None + self._apply_profile(self.dev.profile) + self._apply_device_info() + self._usb_async(self.dev.get_all, self._apply_state) + self._notify_tray() + + def _apply_device_info(self): + info = self.dev.info or {} + self.fw_label.set_label(info.get("fw_version", "—")) + self.api_label.set_label(info.get("api_version", "—")) + self.serial_label.set_label(info.get("serial", "—")) + + def _start_device_watch(self): + """Notice a Wave appearing or vanishing while others stay connected. + + A 3 s sysfs diff against what is held open; any difference funnels + into _try_connect, which rescans everything and keeps the selection. + The disconnected case is _start_reconnect's; this one runs only + while at least one device is open, and stops itself when none is. + """ + if getattr(self, "_device_watch_id", None): + return + self._device_watch_id = GLib.timeout_add_seconds( + 3, self._device_watch_tick) + + def _device_watch_tick(self): + if not self._devs: + self._device_watch_id = None + return False + held = {d.usbbus for d in self._devs} + present = {unit[2] for unit in device_module.present_units()} + if present != held: + self._try_connect() + return True + def _start_reconnect(self): """Watch for a Wave appearing, so plugging one in needs no Refresh. @@ -626,24 +821,131 @@ def _stop_polling(self): self._poll_id = None def _poll_tick(self): - """Called every 100ms — read device state in background.""" - if not self.dev.connected: + """Called every 100ms — read every device's state in background. + + Every unit is polled, not just the selected one: the ALSA sync and + the hardware mute button live inside get_all(), and a device whose + button goes dead the moment another is selected would read as + broken hardware. + """ + if not self._devs: self._poll_id = None return False # stop polling - # Only poll if not already busy with a user-initiated write - self._usb_async(self.dev.get_all, self._on_poll_result, self._on_poll_error) + # One poll in flight at a time. A transfer against a just-unplugged + # device blocks for its full 1 s timeout, and a 100 ms tick that + # spawns regardless stacked ten workers hammering the dying handle. + if getattr(self, "_poll_busy", False): + return True + self._poll_busy = True + def _poll_all(): + gone, state, any_muted, hw_mutes = [], None, False, [] + for d in list(self._devs): + try: + s = d.get_all() + if not d.info: + # devinfo is best-effort at connect; rows pair with + # handles by serial, so keep trying until it reads. + try: + d.info = d.read_device_info() + except Exception: + pass + except Exception: + d.disconnect() + gone.append(d) + continue + muted = bool(s.get("mute")) + any_muted = any_muted or muted + # A CHANGE in a device's own mute — the physical button — + # is what drives its row; steady state drives nothing, so + # row and hardware can still be set apart deliberately. + prev = getattr(d, "_hw_mute_seen", None) + d._hw_mute_seen = muted + if prev is not None and prev != muted: + hw_mutes.append((d, muted)) + if d is self.dev: + state = s + return {"gone": gone, "state": state, "any_muted": any_muted, + "hw_mutes": hw_mutes} + self._usb_async(_poll_all, self._on_poll_result, self._on_poll_error) return True # keep polling - def _on_poll_result(self, state): - if state != self._last_state: + def _source_for_device(self, dev): + """(source_id, source) of the row carrying this device, or (None, None).""" + serial = (dev.info or {}).get("serial", "") + stem = self._NODE_STEMS.get(dev.profile.key, "\0") + stem_hit = None + for sid, source in self._sources.items(): + if sources_module.kind(source) != sources_module.KIND_DEVICE: + continue + node = source.get("node_name") or "" + if serial and serial in node: + return sid, source + if stem in node: + stem_hit = (sid, source) if stem_hit is None else (None, None) + return stem_hit or (None, None) + + def _set_row_mute_from_hardware(self, dev, muted): + """The physical mute button reaches the matrix row, like the row + reaches the hardware. Deliberately NOT through _sync_hw_mute — the + hardware is already in the new state, and writing it back would + turn the pair into a loop.""" + source_id, source = self._source_for_device(dev) + if source is None or bool(source.get("muted", False)) == muted: + return + source["muted"] = muted + self.mixer.set_source_level(source_id, source.get("level", 1.0), muted) + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_muted(muted) + if not muted: + self._enforce_exclusive_group(source_id) + sources_module.save(self._sources) + self._notify_tray() + + def _on_poll_result(self, result): + self._poll_busy = False + for dev, muted in result.get("hw_mutes", ()): + self._set_row_mute_from_hardware(dev, muted) + muted_changed = result["any_muted"] != self._any_hw_muted + self._any_hw_muted = result["any_muted"] + if result["gone"]: + self._devs = [d for d in self._devs if d not in result["gone"]] + if self.dev in result["gone"]: + self._select_surviving_device() + return + self._refresh_device_selector() + state = result["state"] + if state is not None and state != self._last_state: self._apply_state(state) + elif muted_changed: + self._notify_tray() + + def _select_surviving_device(self): + """The selected unit vanished; fall back to another or to none.""" + if self._devs: + self.dev = self._devs[0] + self._last_state = None + self._refresh_device_selector() + self._apply_profile(self.dev.profile) + self._apply_device_info() + self._usb_async(self.dev.get_all, self._apply_state) + self._notify_tray() + else: + self._window_title.set_subtitle("Disconnected") + self._stop_polling() + self._refresh_device_selector() + self._notify_tray() + self._start_reconnect() def _on_poll_error(self, e): - self._window_title.set_subtitle("Disconnected") + self._poll_busy = False + # _poll_all swallows per-device errors; reaching here means the + # poll machinery itself failed. Treat it as everything gone. + for d in self._devs: + d.disconnect() + self._devs = [] self.dev.disconnect() - self._stop_polling() - self._notify_tray() - self._start_reconnect() + self._select_surviving_device() def _apply_profile(self, profile): """Adapt the UI to the connected device model.""" @@ -709,11 +1011,10 @@ def _notify_tray(self): app.refresh_tray() def _on_usb_error(self, e): - self._window_title.set_subtitle("Disconnected") + """A write to the selected device failed: drop that unit only.""" self.dev.disconnect() - self._stop_polling() - self._notify_tray() - self._start_reconnect() + self._devs = [d for d in self._devs if d is not self.dev] + self._select_surviving_device() def _on_mute_changed(self, row, _pspec): if self._updating_ui or not self.dev.connected: @@ -787,6 +1088,38 @@ def _refresh_outputs(self): mix_id, entries, current, summary, monitored, ) + def _on_mix_volume_changed(self, _matrix, mix_id, value): + """A header's master slider moved: throttled like every live slider, + because a drag would otherwise spawn a wpctl per pixel.""" + self._throttle.push( + f"mixvol:{mix_id}", value, + lambda v, mid=mix_id: self._usb_async( + lambda: self.mixer.set_mix_volume(mid, v))) + + def _refresh_mix_meter(self, mix_id, mix): + """Point a meter at the mix's sink (its monitor carries the audio). + + Re-pointed idempotently from the stream tick: installing mixes + destroys and recreates their sinks, which kills the pw-cat under + the meter — running() going false is how that is noticed. + """ + key = f"mix:{mix_id}" + sink = mix.get("sink") + if not sink: + return + if self._meter_targets.get(key) == sink and self.meter.running(key): + return + self._meter_targets[key] = sink + self.meter.start( + key, sink, + lambda level, mid=mix_id: self.matrix.set_mix_level(mid, level), + capture_sink=True) + + def _stop_mix_meter(self, mix_id): + key = f"mix:{mix_id}" + if self._meter_targets.pop(key, None) is not None: + self.meter.stop(key) + def _on_mix_output_changed(self, _matrix, mix_id, name): self.mixer.set_output(mix_id, name) # Re-label "Automatic — " once the mixer has retargeted the @@ -912,6 +1245,7 @@ def _on_remove_mix_response(self, dialog, result, mix_id): # mixer, which captures the sink name before dropping the definition and # on its worker tears every loopback down before destroying the sink; # then the definition and the generated config catch up. + self._stop_mix_meter(mix_id) self.matrix.remove_mix(mix_id) self.mixer.remove_mix(mix_id) self._mixes = mixes_module.remove(self._mixes, mix_id) @@ -1000,6 +1334,11 @@ def _stream_poll_tick(self): if check_devices: self._device_poll_countdown = self._DEVICE_POLL_EVERY self.mixer.request_capture_poll() + # Discovery is not a launch-time-only event: a Wave plugged in + # (or back in, after its row was removed while unplugged) should + # get its row now, not on the next restart. Idempotent — bound + # and already-offered nodes are skipped. + self._autodiscover_elgato_inputs() for source_id, source in list(self._sources.items()): if sources_module.kind(source) == sources_module.KIND_DEVICE: if check_devices: @@ -1007,6 +1346,13 @@ def _stream_poll_tick(self): self._check_capture_stall(source_id, source) else: self._refresh_app_meter(source_id) + for mix_id, mix in list(self._mixes.items()): + self._refresh_mix_meter(mix_id, mix) + # observe_mix_volumes just ran, so this follows an external move + # (pavucontrol, a media key, a scene) within one tick. + remembered = self.mixer.mix_volume(mix_id) + if remembered is not None: + self.matrix.set_mix_volume(mix_id, remembered[0]) return True def _check_capture_stall(self, source_id, source): @@ -1070,6 +1416,13 @@ def _refresh_device_meter(self, source_id, source): cell = self.matrix.source(source_id) if cell is not None: cell.set_available(present, reason="Capture device not connected") + # Removability follows presence: a connected Elgato row stays + # protected, an unplugged one may be cleared away (it returns by + # autodiscovery if the device is plugged back in). + if sources_module.is_protected(source): + cell.set_removable( + not present, + tooltip="Remove row (device not connected)") if not present: # Stop rather than leave pw-cat holding a device that has gone, and # zero the bar so it does not freeze on its last value. @@ -1206,7 +1559,9 @@ def _install_source(self, source): name=source["name"], icon_name=source["icon_name"], has_level=True, - removable=not sources_module.is_protected(source), + removable=(not sources_module.is_protected(source) + or sources_module.kind(source) + == sources_module.KIND_DEVICE), editable=True, reorderable=True, is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, @@ -1302,6 +1657,10 @@ def _wire_source_row(self, source_id): cell = self.matrix.source(source_id) if cell is None: return + # Protected device rows carry a remove button that starts hidden; + # the presence tick shows it only while the device is unplugged. + if sources_module.is_protected(self._sources.get(source_id, {})): + cell.set_removable(False) source = self._sources.get(source_id, {}) cell.set_volume(float(source.get("level", 1.0))) cell.set_muted(bool(source.get("muted", False))) @@ -1317,6 +1676,7 @@ def _on_source_level_changed(self, _cell, volume, source_id): def _on_source_mute_toggled(self, _cell, muted, source_id): self.mixer.set_source_level( source_id, self._sources.get(source_id, {}).get("level", 1.0), muted) + self._sync_hw_mute(self._sources.get(source_id, {}), muted) if not muted: self._enforce_exclusive_group(source_id) sources_module.save(self._sources) @@ -1396,6 +1756,67 @@ def set_source_volume(self, source_id, level): sources_module.save(self._sources) return True + # How each protocol profile's hardware names its capture node. Used to + # pair a row with a handle when the devinfo serial is unavailable. + _NODE_STEMS = { + "wave_xlr": "Elgato_Wave_XLR_", + "wave_xlr_mk2": "Elgato_XLR_Dock_", + "wave3": "Elgato_Wave_3", + } + + def _device_for_source(self, source): + """The open WaveDevice behind an Elgato capture row, or None. + + Matched by serial first: the ALSA node name embeds it + ("...Elgato_XLR_Dock_-00..."), so a row pairs with the + right USB handle even with two devices of the same model. When the + serial could not be read (devinfo is best-effort at connect), the + model's node stem decides — but only while exactly one device of + that model is open, because a guess between two identical units + would mute the wrong microphone. + """ + node = source.get("node_name") or "" + for dev in self._devs: + serial = (dev.info or {}).get("serial") + if serial and serial in node: + return dev + candidates = [ + dev for dev in self._devs + if self._NODE_STEMS.get(dev.profile.key, "\0") in node + ] + if len(candidates) == 1: + return candidates[0] + return None + + def _sync_hw_mute(self, source, muted): + """Mirror a row mute onto the device's own mute, like the sidebar. + + A muted Elgato row that leaves the hardware live reads as a lying + mute button: the device's LED says on-air while the matrix drops + the audio. Row mute therefore drives the firmware too — from a + click, the session bus, a scene, or a group hand-over alike. The + reverse direction stays hands-off: the hardware button is polled + into the sidebar, not into the matrix, so no loop. + """ + dev = self._device_for_source(source) + if dev is None: + if source.get("node_name", "").find("Elgato") >= 0: + logging.warning( + "row mute for %s: no open device matched, hardware " + "mute not mirrored", source.get("name")) + return + self._usb_async( + lambda: dev.set_mute(bool(muted)), + on_error=lambda e: logging.warning( + "hardware mute mirror failed for %s: %s", + source.get("name"), e)) + if dev is self.dev: + self._updating_ui = True + try: + self.mute_row.set_active(bool(muted)) + finally: + self._updating_ui = False + def toggle_source_mute(self, source_id): """Flip a source's mute. Returns the new state, or None if unknown. @@ -1412,6 +1833,7 @@ def toggle_source_mute(self, source_id): if cell is not None: cell.set_muted(muted) self.mixer.set_source_level(source_id, source.get("level", 1.0), muted) + self._sync_hw_mute(source, muted) if not muted: self._enforce_exclusive_group(source_id) sources_module.save(self._sources) @@ -1450,6 +1872,191 @@ def toggle_cell_mute(self, source_id, mix_id): self._refresh_mix_emptiness() return muted + # --- Scenes ----------------------------------------------------------- + + def scene_names(self): + """{scene id: display name} for every stored scene.""" + return {sid: s.get("name", sid) + for sid, s in scenes_module.load().items()} + + def save_scene(self, name): + """Capture the current levels — matrix and hardware — under a name.""" + if not name or not name.strip(): + return None + payload = self.mixer.scene_state() + hardware = self._hardware_scene_state() + if hardware: + payload["hardware"] = hardware + sid = scenes_module.put(name.strip(), payload) + self._rebuild_scene_menu() + return sid + + def apply_scene(self, sid): + """Recall a scene. Returns what was skipped, or None if it is gone. + + Partial apply is normal: a scene naming a source or mix that no + longer exists sets what still matches and reports the rest. Sources + and cells go through the window's own setters so the widgets follow; + outputs and masters go through the mixer, which owns them. + """ + scene = scenes_module.load().get(sid) + if scene is None: + return None + skipped = [] + + for source_id, entry in (scene.get("sources") or {}).items(): + source = self._sources.get(source_id) + if source is None: + skipped.append(f"source {source_id}") + continue + self.set_source_volume(source_id, entry.get("level", 1.0)) + if bool(source.get("muted", False)) != bool(entry.get("muted")): + # Toggle rather than write: unmuting a grouped microphone + # must take the group with it, however the unmute arrived. + self.toggle_source_mute(source_id) + + for key, cell in (scene.get("cells") or {}).items(): + source_id, _, mix_id = key.rpartition(".") + if source_id not in self._sources or mix_id not in self._mixes: + skipped.append(f"cell {key}") + continue + current = self.mixer.get_cell(source_id, mix_id) + self.set_cell_volume(source_id, mix_id, cell.get("volume", 0.0)) + if bool(current["muted"]) != bool(cell.get("muted", False)): + self.toggle_cell_mute(source_id, mix_id) + + skipped += self.mixer.apply_scene({ + "outputs": scene.get("outputs") or {}, + "volumes": scene.get("volumes") or {}, + }) + self._apply_scene_hardware(scene.get("hardware") or {}, skipped) + self._refresh_outputs() + self._refresh_mix_emptiness() + if skipped: + logging.info("scene %s: skipped %s", sid, ", ".join(skipped)) + return skipped + + def delete_scene(self, sid): + removed = scenes_module.remove(sid) + if removed: + self._rebuild_scene_menu() + return removed + + def _hardware_scene_state(self): + """Every connected device's state, keyed by profile:serial, or {}. + + Serial-keyed because two units of one model are different devices + with different gains; a scene keyed by model alone could only ever + describe one of them. + """ + hardware = {} + keep = ("gain_raw", "mute", "hp_volume_db", "low_impedance", + "phantom", "monitor_mix") + for dev in self._devs: + try: + state = dev.get_all() + except Exception: + continue + key = scenes_module.hardware_key( + dev.profile.key, (dev.info or {}).get("serial", "")) + hardware[key] = {k: state[k] for k in keep if k in state} + return hardware + + def _apply_scene_hardware(self, hardware, skipped): + """Apply a scene's device sections to whichever devices are here. + + Each connected device picks its entry — exact serial first, then + the model (pre-serial scenes, or a replaced unit). Devices with no + entry and entries with no device both skip silently, except that a + scene carrying hardware with nothing connected at all is reported. + The gain lock wins over the selected device's gain — a locked + slider rejects a recall the same way it rejects a drag. + """ + if not hardware: + return + if not self._devs: + skipped.append("hardware") + return + gain_locked = bool(getattr(self, "gain_lock", None) + and self.gain_lock.get_active()) + jobs = [] + for dev in self._devs: + entry = scenes_module.pick_hardware_entry( + hardware, dev.profile.key, (dev.info or {}).get("serial", "")) + if entry is not None: + jobs.append((dev, dict(entry), + gain_locked and dev is self.dev)) + if not jobs: + skipped.append("hardware") + return + + def _push(): + for dev, entry, locked in jobs: + if "gain_raw" in entry and not locked: + dev.set_gain_raw(int(entry["gain_raw"])) + if "mute" in entry: + dev.set_mute(bool(entry["mute"])) + if "hp_volume_db" in entry: + dev.set_hp_volume_db(float(entry["hp_volume_db"])) + if "low_impedance" in entry: + dev.set_low_impedance(bool(entry["low_impedance"])) + if "phantom" in entry: + dev.set_phantom(bool(entry["phantom"])) + if "monitor_mix" in entry: + dev.set_monitor_mix(int(entry["monitor_mix"])) + return self.dev.get_all() if self.dev.connected else None + + def _done(state): + if state is not None: + self._apply_state(state) + + self._usb_async(_push, on_done=_done) + + def _rebuild_scene_menu(self): + menu = Gio.Menu() + names = sorted(self.scene_names().items(), key=lambda kv: kv[1].lower()) + + recall = Gio.Menu() + for sid, name in names: + item = Gio.MenuItem.new(name, None) + item.set_action_and_target_value( + "app.apply-scene", GLib.Variant("s", sid)) + recall.append_item(item) + if names: + menu.append_section(None, recall) + + manage = Gio.Menu() + manage.append("Save current as…", "win.save-scene-as") + if names: + delete = Gio.Menu() + for sid, name in names: + item = Gio.MenuItem.new(name, None) + item.set_action_and_target_value( + "app.delete-scene", GLib.Variant("s", sid)) + delete.append_item(item) + manage.append_submenu("Delete scene", delete) + menu.append_section(None, manage) + self.scene_btn.set_menu_model(menu) + + def prompt_save_scene(self): + dialog = Adw.AlertDialog( + heading="Save Scene", + body="Every trim, send, mute, output, master and device setting, " + "as they are right now. Saving an existing name replaces it.", + ) + entry = Gtk.Entry(placeholder_text="Streaming") + dialog.set_extra_child(entry) + dialog.add_response("cancel", "Cancel") + dialog.add_response("save", "Save") + dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("save") + + def _done(d, result): + if d.choose_finish(result) == "save": + self.save_scene(entry.get_text()) + + dialog.choose(self, None, _done) + def remote_snapshot(self): """Everything a remote control needs to draw a button, as JSON.""" return json.dumps({ @@ -1519,6 +2126,7 @@ def _on_switch_source_clicked(self, _matrix, source_id): cell = self.matrix.source(target_id) if cell is not None: cell.set_muted(False) + self._sync_hw_mute(target, False) self._enforce_exclusive_group(target_id) sources_module.save(self._sources) @@ -1547,6 +2155,9 @@ def _enforce_exclusive_group(self, active_id): cell = self.matrix.source(sid) if cell is not None: cell.set_muted(True) + # A group hand-over hardware-mutes the loser too, so its on-air + # LED goes dark with the row instead of contradicting it. + self._sync_hw_mute(source, True) def _on_move_source_clicked(self, _matrix, source_id, delta): before = list(self._sources) @@ -1617,10 +2228,15 @@ def _on_source_edited(self, _dialog, source_id, name, binding, icon_name, def _on_remove_source_clicked(self, _matrix, source_id): source = self._sources.get(source_id, {}) name = source.get("name", "this source") + if sources_module.is_protected(source): + body = (f"This deletes “{name}” and its mix levels. If the device " + f"is plugged back in, the row is offered again.") + else: + body = (f"This deletes “{name}” and its mix levels. The bound " + f"application itself is not affected.") dialog = Adw.AlertDialog( heading="Remove source?", - body=f"This deletes “{name}” and its mix levels. The bound application " - f"itself is not affected.", + body=body, ) dialog.add_response("cancel", "Cancel") dialog.add_response("remove", "Remove") @@ -1631,6 +2247,14 @@ def _on_remove_source_clicked(self, _matrix, source_id): def _on_remove_response(self, dialog, result, source_id): if dialog.choose_finish(result) != "remove": return + source = self._sources.get(source_id, {}) + if sources_module.is_protected(source): + # Removing an unplugged device's row means "clean this up", not + # "never again": forgetting the node lets autodiscovery offer + # the row afresh when the device returns. (A plain app row the + # user deletes stays deleted — that memory is per offered node.) + self._offered_nodes.discard(source.get("node_name")) + self._save_ui_state() self.meter.stop(source_id) self._meter_targets.pop(source_id, None) self.matrix.remove_source(source_id) @@ -1731,6 +2355,27 @@ def _register_remote_actions(self): snapshot.connect("activate", self._action_refresh_snapshot) self.add_action(snapshot) + apply_scene = Gio.SimpleAction.new( + "apply-scene", GLib.VariantType.new("s")) + apply_scene.connect("activate", self._action_apply_scene) + self.add_action(apply_scene) + + save_scene = Gio.SimpleAction.new( + "save-scene", GLib.VariantType.new("s")) + save_scene.connect("activate", self._action_save_scene) + self.add_action(save_scene) + + delete_scene = Gio.SimpleAction.new( + "delete-scene", GLib.VariantType.new("s")) + delete_scene.connect("activate", self._action_delete_scene) + self.add_action(delete_scene) + + scenes_state = Gio.SimpleAction.new_stateful( + "scenes", None, GLib.Variant("s", "{}"), + ) + scenes_state.connect("activate", self._action_refresh_scenes) + self.add_action(scenes_state) + def _action_switch_group(self, _action, parameter): if self._window is None or parameter is None: return @@ -1803,6 +2448,40 @@ def _action_refresh_snapshot(self, action, _parameter): except Exception: # noqa: BLE001 logging.exception("snapshot failed") + def _action_apply_scene(self, _action, parameter): + if self._window is None or parameter is None: + return + try: + self._window.apply_scene(parameter.get_string()) + except Exception: # noqa: BLE001 + logging.exception("apply-scene failed") + + def _action_save_scene(self, _action, parameter): + if self._window is None or parameter is None: + return + try: + self._window.save_scene(parameter.get_string()) + except Exception: # noqa: BLE001 + logging.exception("save-scene failed") + + def _action_delete_scene(self, _action, parameter): + if self._window is None or parameter is None: + return + try: + self._window.delete_scene(parameter.get_string()) + except Exception: # noqa: BLE001 + logging.exception("delete-scene failed") + + def _action_refresh_scenes(self, action, _parameter): + """Publish {scene id: name} as JSON state, activate-then-describe.""" + if self._window is None: + return + try: + action.set_state(GLib.Variant( + "s", json.dumps(self._window.scene_names()))) + except Exception: # noqa: BLE001 + logging.exception("scenes failed") + def do_command_line(self, command_line): options = command_line.get_options_dict() if options.contains("hide"): @@ -1908,7 +2587,10 @@ def refresh_tray(self): state = window._last_state or {} self._tray.set_state( bool(window.dev.connected), - bool(state.get("mute", False)), + # Any device's hardware mute counts: with two microphones the + # tray answering only for the selected one would show "live" + # while the mic actually in use is muted. + bool(state.get("mute", False)) or window._any_hw_muted, window.capture_rows_muted(), ) diff --git a/wavexlr/meter.py b/wavexlr/meter.py index 0a64a2c..3990507 100644 --- a/wavexlr/meter.py +++ b/wavexlr/meter.py @@ -24,7 +24,12 @@ class MeterMonitor: SAMPLE_RATE = 8000 - CHUNK_BYTES = 256 # ~16 ms of s16 mono @ 8 kHz → ~60 Hz updates + # ~64 ms of s16 mono @ 8 kHz → ~15 Hz updates. Was 256 bytes / 60 Hz, + # which cost a GLib.idle_add per chunk per meter — over 400 main-loop + # wakeups a second across seven meters, for bars the eye cannot follow + # past ~15 Hz anyway. The peak of a 64 ms window still catches every + # transient; it is the standard meter integration ballpark. + CHUNK_BYTES = 1024 def __init__(self): self._procs = {} # source_id -> Popen @@ -37,25 +42,34 @@ def __init__(self): # microphone in a quiet room is legitimately near zero. self._last_data = {} # source_id -> monotonic seconds - def start(self, source_id, source_node_name, callback): + def start(self, source_id, source_node_name, callback, capture_sink=False): """Begin streaming peak values for `source_id`. Replaces any existing meter for that id. `callback(level: float)` is invoked on the main - thread at the chunk rate.""" + thread at the chunk rate. + + `capture_sink=True` meters a SINK by its monitor. Without it a + record stream targeting a sink is not an error: the session manager + quietly links it to the default source instead, so a mix meter + showed whatever microphone happened to be the default input. + """ if source_id in self._procs: self.stop(source_id) + props = { + # Labelled so a level tap is identifiable in a mixer or a + # monitoring script. Unlabelled these appear as bare + # "pw-cat" entries indistinguishable from anyone else's. + "node.name": f"openwave_meter_{source_id}", + "node.description": f"OpenWave level meter ({source_id})", + "application.name": "OpenWave", + } + if capture_sink: + props["stream.capture.sink"] = True try: proc = subprocess.Popen( [ "pw-cat", "--record", "--target", source_node_name, - # Labelled so a level tap is identifiable in a mixer or a - # monitoring script. Unlabelled these appear as bare - # "pw-cat" entries indistinguishable from anyone else's. - "--properties", json.dumps({ - "node.name": f"openwave_meter_{source_id}", - "node.description": f"OpenWave level meter ({source_id})", - "application.name": "OpenWave", - }), + "--properties", json.dumps(props), "--rate", str(self.SAMPLE_RATE), "--channels", "1", "--format", "s16", @@ -84,6 +98,11 @@ def start(self, source_id, source_node_name, callback): self._last_data[source_id] = time.monotonic() thread.start() + def running(self, source_id): + """Whether a live meter subprocess exists for this id.""" + proc = self._procs.get(source_id) + return proc is not None and proc.poll() is None + def stop(self, source_id): flag = self._stop_flags.pop(source_id, None) if flag is not None: diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 1c059e2..86a80fe 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -47,6 +47,7 @@ class MixMatrix(Gtk.Box): "remove-mix-clicked": (GObject.SignalFlags.RUN_FIRST, None, (str,)), # (mix_id, output name — a sink node.name, OUTPUT_AUTO or OUTPUT_NONE) "mix-output-changed": (GObject.SignalFlags.RUN_FIRST, None, (str, str)), + "mix-volume-changed": (GObject.SignalFlags.RUN_FIRST, None, (str, float)), } # Shown instead of deleting the only mix. The matrix's whole geometry is @@ -70,7 +71,9 @@ def __init__(self): margin_start=12, margin_end=12, margin_top=12, - margin_bottom=0, + # Matches the other three sides: at zero the last source row sat + # flush against the window edge and read as clipped. + margin_bottom=12, ) wrapper.append(self._grid) @@ -125,6 +128,10 @@ def add_mix(self, mix_id, *, title, subtitle, icon_name): "output-changed", lambda _h, name, mid=mix_id: self.emit("mix-output-changed", mid, name), ) + header.connect( + "volume-changed", + lambda _h, value, mid=mix_id: self.emit("mix-volume-changed", mid, value), + ) header.connect( "rename-clicked", lambda _h, mid=mix_id: self.emit("rename-mix-clicked", mid), ) @@ -164,6 +171,16 @@ def _sync_delete_sensitivity(self): for header in self._headers.values(): header.set_delete_enabled(enabled, self.LAST_MIX_REASON) + def set_mix_volume(self, mix_id, value): + header = self._headers.get(mix_id) + if header is not None: + header.set_volume(value) + + def set_mix_level(self, mix_id, value): + header = self._headers.get(mix_id) + if header is not None: + header.set_level(value) + def set_mix_empty(self, mix_id, empty): header = self._headers.get(mix_id) if header is not None: @@ -389,6 +406,7 @@ class MixHeaderCell(Gtk.Box): __gsignals__ = { "output-changed": (GObject.SignalFlags.RUN_FIRST, None, (str,)), + "volume-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "rename-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "remove-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), } @@ -471,6 +489,48 @@ def __init__(self, *, title, subtitle, icon_name): self._out_lbl.add_css_class("caption") self._out_box.append(self._out_lbl) + # Master volume + live level. The master is a plain PipeWire sink + # volume anything may move (pavucontrol, a media key, a scene), so + # the slider is set from observation as much as it drives — writes + # go out through volume-changed, external moves come back through + # set_volume with the handler blocked. + vol_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + text.append(vol_row) + self._vol_scale = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, + draw_value=False, + adjustment=Gtk.Adjustment( + lower=0.0, upper=1.0, step_increment=0.01, page_increment=0.05 + ), + hexpand=True, + valign=Gtk.Align.CENTER, + round_digits=2, + ) + self._vol_scale.add_css_class("openwave-mix-slider") + self._vol_scale.set_tooltip_text("Mix master volume") + self._vol_handler = self._vol_scale.connect( + "value-changed", self._on_volume_changed) + vol_row.append(self._vol_scale) + self._vol_pct = Gtk.Label(label="", xalign=1) + self._vol_pct.add_css_class("dim-label") + self._vol_pct.add_css_class("caption") + self._vol_pct.set_width_chars(4) + vol_row.append(self._vol_pct) + + self._level = Gtk.LevelBar( + orientation=Gtk.Orientation.HORIZONTAL, + mode=Gtk.LevelBarMode.CONTINUOUS, + min_value=0.0, + max_value=1.0, + valign=Gtk.Align.CENTER, + ) + self._level.set_size_request(-1, 6) + self._level.add_css_class("openwave-level") + self._level.add_offset_value(Gtk.LEVEL_BAR_OFFSET_LOW, 0.70) + self._level.add_offset_value(Gtk.LEVEL_BAR_OFFSET_HIGH, 0.90) + self._level.add_offset_value(Gtk.LEVEL_BAR_OFFSET_FULL, 1.00) + text.append(self._level) + self._menu_btn = Gtk.MenuButton( icon_name="view-more-symbolic", valign=Gtk.Align.CENTER, @@ -574,7 +634,43 @@ def _on_delete_clicked(self, _btn): self._popdown() self.emit("remove-clicked") + def _on_volume_changed(self, scale): + value = scale.get_value() + self._vol_pct.set_label(f"{round(value * 100):d}%") + self.emit("volume-changed", value) + # ----- setters ----- + def set_volume(self, value): + """Reflect the master without firing the changed signal.""" + value = max(0.0, min(1.0, value)) + with GObject.signal_handler_block(self._vol_scale, self._vol_handler): + self._vol_scale.set_value(value) + self._vol_pct.set_label(f"{round(value * 100):d}%") + + def set_level(self, value): + """Update the mix's live audio level bar from a raw peak (0.0–1.0). + + Displayed as the CUBE root of amplitude, deliberately matching the + faders: cell and trim volumes are written with wpctl, whose taper + is cubic — a fader at 30% is 2.7% linear amplitude. Measured: a + 0.31-peak source through a 0.3 fader arrives at the mix at 0.0084, + exactly 0.31 × 0.3³. A linear (or sqrt) bar therefore sat near + zero while the audio sounded like "30%"; on the cubic scale the + bar and the faders speak the same language, and full scale is + still full scale. + + Peak-hold with decay on top: updates arrive per ~16 ms chunk at + ~60 Hz, and painting each chunk's own peak raw made the bar flicker + around the quiet windows between transients — reading far lower + than the audio. A new peak takes instantly; between peaks the + display decays with a ~140 ms half-life at the meter's ~15 Hz + update rate, which is how a hardware meter ballistically behaves. + """ + shown = max(0.0, min(1.0, value)) ** (1.0 / 3.0) + held = getattr(self, "_level_held", 0.0) * 0.72 + self._level_held = max(shown, held) + self._level.set_value(self._level_held) + def set_title(self, title): self._title_lbl.set_label(title) self._title_lbl.set_tooltip_text(title) @@ -822,16 +918,30 @@ def __init__(self, *, name, icon_name, has_level, removable=False, edit_btn.connect("clicked", lambda _: self.emit("edit-clicked")) inner.append(edit_btn) + self._remove_btn = None if removable: - remove_btn = Gtk.Button( + self._remove_btn = Gtk.Button( icon_name="window-close-symbolic", valign=Gtk.Align.CENTER, tooltip_text="Remove source", ) - remove_btn.add_css_class("flat") - remove_btn.add_css_class("circular") - remove_btn.connect("clicked", lambda _: self.emit("remove-clicked")) - inner.append(remove_btn) + self._remove_btn.add_css_class("flat") + self._remove_btn.add_css_class("circular") + self._remove_btn.connect( + "clicked", lambda _: self.emit("remove-clicked")) + inner.append(self._remove_btn) + + def set_removable(self, removable, tooltip="Remove source"): + """Show or hide the remove button on a row that owns one. + + Auto-discovered device rows are built with the button and normally + hide it: while the hardware is connected, removing its row would + only make it come back confusing. Unplugged, the row is clutter the + user may clear — so removability follows presence. + """ + if self._remove_btn is not None: + self._remove_btn.set_visible(removable) + self._remove_btn.set_tooltip_text(tooltip) def set_group(self, group): """Show which exclusivity group this row is in, if any.""" From 104eb4cfc490f3fa255651186e13c41ef1b64337 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:26:52 -0500 Subject: [PATCH 75/99] Offer an experimental Flatpak for the panel and the matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- CHANGELOG.md | 3 + README.md | 19 ++++ packaging/flatpak/com.github.openwave.yml | 107 ++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 packaging/flatpak/com.github.openwave.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index f27c23d..f3675eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ versions are git tags (see [Releases](../../releases)). pair with USB handles by serial (node-stem fallback when a serial will not read), so two units of one model each follow their own row, and only state *changes* propagate, so the pair cannot loop. +- **Experimental Flatpak manifest** (`packaging/flatpak/`): the control + panel and matrix in a sandbox, driving the host PipeWire; udev rules, + first-run setup and the capture-fix daemon remain native-only. - **Devices are discovered while running**: a Wave plugged in mid-session — or plugged back in after its row was removed — gets its row within seconds instead of on the next launch. diff --git a/README.md b/README.md index f51cae4..d3d61bd 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,25 @@ them declaratively: services.udev.packages = [ openwave ]; ``` +### Flatpak (experimental) + +A manifest lives at +[`packaging/flatpak/com.github.openwave.yml`](packaging/flatpak/com.github.openwave.yml): + +```bash +flatpak install --user flathub org.flatpak.Builder org.gnome.Platform//48 org.gnome.Sdk//48 +flatpak run org.flatpak.Builder --user --install --force-clean build-dir \ + packaging/flatpak/com.github.openwave.yml +``` + +Know the limits before choosing it: the sandbox cannot install udev rules +(grant USB access once from a native install or by hand — see +[docs/hardware-support.md](docs/hardware-support.md)) and cannot run the +first-run setup or the capture-fix daemon, so those stay native. The +manifest bundles the `pw-*`/`wpctl`/`amixer` tools the mixer shells out +to and drives the host PipeWire through its socket. Prefer a native +package where one exists. + ### Uninstall ```bash diff --git a/packaging/flatpak/com.github.openwave.yml b/packaging/flatpak/com.github.openwave.yml new file mode 100644 index 0000000..54e13a3 --- /dev/null +++ b/packaging/flatpak/com.github.openwave.yml @@ -0,0 +1,107 @@ +# Flatpak manifest — EXPERIMENTAL. +# +# Build: +# flatpak install --user flathub org.flatpak.Builder org.gnome.Platform//48 org.gnome.Sdk//48 +# flatpak run org.flatpak.Builder --user --install --force-clean build-dir \ +# packaging/flatpak/com.github.openwave.yml +# +# What works in the sandbox and what cannot: +# +# - USB control needs --device=all: OpenWave speaks raw libusb, and there +# is no portal for vendor control transfers. Host udev rules are still +# what grants the permission — the sandbox cannot install them, so run +# the udev step once outside (see docs/hardware-support.md) or install +# any native package first. +# - The mixing matrix drives the HOST PipeWire through its socket; the +# pw-* / wpctl / pactl tools the mixer shells out to are bundled below +# so they exist inside the sandbox. +# - First-run setup (pkexec udev + service install) cannot run sandboxed +# and is skipped; the capture-fix daemon must come from a native +# install or not at all. The GUI itself is fully functional without it +# on hardware that does not hit the firmware race. +# +# In short: the Flatpak is the control panel + matrix; the system pieces +# stay native. Prefer a native package where one exists. + +app-id: com.github.openwave +runtime: org.gnome.Platform +runtime-version: "48" +sdk: org.gnome.Sdk +command: openwave + +finish-args: + - --share=ipc + - --socket=wayland + - --socket=fallback-x11 + # Raw libusb control transfers; also /dev/snd for amixer. + - --device=all + # The host PipeWire graph is the whole point. + - --filesystem=xdg-run/pipewire-0 + - --socket=pulseaudio + # ALSA card topology (amixer numids, usbid matching). + - --filesystem=/proc/asound:ro + # Generated PipeWire/WirePlumber user config, autostart + drawer entries. + - --filesystem=xdg-config/pipewire:create + - --filesystem=xdg-config/wireplumber:create + - --filesystem=xdg-config/autostart:create + - --filesystem=xdg-data/applications:create + # Tray. + - --talk-name=org.kde.StatusNotifierWatcher + +modules: + # amixer — the ALSA sync path shells out to it. + - name: alsa-utils + sources: + - type: git + url: https://github.com/alsa-project/alsa-utils.git + tag: v1.2.13 + commit: f04b9e0f1285fe25d4146906c5a8511741b8abad + config-opts: + - --disable-alsamixer + - --disable-xmlto + - --disable-nls + + # pw-cat, pw-cli, pw-dump, pw-link, pw-loopback — the router's hands. + - name: pipewire-tools + buildsystem: meson + config-opts: + - -Dsession-managers=[] + - -Dexamples=disabled + - -Dtests=disabled + - -Dgstreamer=disabled + - -Dsystemd=disabled + - -Dudevrulesdir=/app/lib/udev/rules.d + sources: + - type: git + url: https://gitlab.freedesktop.org/pipewire/pipewire.git + tag: "1.4.2" + commit: d20a1523b6770dfa93a270bdda5d7c800d7ec191 + + # wpctl — volumes are written through it. + - name: wireplumber + buildsystem: meson + config-opts: + - -Dsystemd=disabled + - -Delogind=disabled + - -Ddoc=disabled + - -Dintrospection=disabled + - -Dtests=disabled + sources: + - type: git + url: https://gitlab.freedesktop.org/pipewire/wireplumber.git + tag: "0.5.10" + commit: 7a4d3177550b6b53fe0a49396da5b07f5353daff + + - name: openwave + buildsystem: simple + build-commands: + # SITEPKG must land under /app (the interpreter's own site-packages + # is the read-only runtime), and /app/lib/pythonX.Y/site-packages is + # on the runtime python's default path. + - >- + make install PREFIX=/app PYTHON=python3 + SITEPKG=/app/lib/python$(python3 -c "import sys; + print('%d.%d' % sys.version_info[:2])")/site-packages + sources: + - type: dir + path: ../.. From 42cdd7b316793bb3cbcd66826534f0968e9b701b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:40:20 -0500 Subject: [PATCH 76/99] Respawn a device's cell loopbacks when its node comes back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_mixer_reconcile.py | 21 +++++++++++++++++++++ wavexlr/mixer.py | 31 +++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/tests/test_mixer_reconcile.py b/tests/test_mixer_reconcile.py index 2ddb844..b2b5a4f 100644 --- a/tests/test_mixer_reconcile.py +++ b/tests/test_mixer_reconcile.py @@ -61,6 +61,27 @@ def test_the_cell_fader_composes_with_the_source_trim(self): self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) self.assertIn(("wpctl", "set-volume", "77", "0.400"), self.pw.calls) + def test_a_reappeared_device_gets_fresh_loopbacks(self): + """A replug (or a recovery card-cycle) is a new node wearing the old + name. The old loopback was hand-linked to the corpse — alive, + healthy, and carrying nothing — so the arrival of the node must kill + it and let the reconcile respawn against the reincarnation.""" + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + first = self.pw.spawned[0] + + self.mx._drop_device_cell_loopbacks(frozenset({ARCTIS})) + self.assertTrue(first.terminated, + "the orphaned loopback must not survive the replug") + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + self.assertEqual(len(self.pw.spawned), 2, + "the reconcile must respawn a fresh loopback") + + def test_other_devices_loopbacks_are_left_alone(self): + self.mx._reconcile_capture_cell("dock", "personal", ARCTIS, 0.8, False) + first = self.pw.spawned[0] + self.mx._drop_device_cell_loopbacks(frozenset({"some_other_node"})) + self.assertFalse(first.terminated) + def test_a_muted_source_silences_the_cell_without_tearing_it_down(self): self.mx._sources["dock"]["muted"] = True self.pw.node_ids[self.loop_name()] = "77" diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 00c642c..1600efb 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1469,9 +1469,36 @@ def poll_capture_devices(self): """ added, removed = self._refresh_live_captures() if added or removed: - self._enqueue(("poll",), self._reconcile_all) + self._enqueue( + ("poll",), + lambda nodes=frozenset(added): self._on_captures_moved(nodes)) return added, removed + def _on_captures_moved(self, added): + self._drop_device_cell_loopbacks(added) + self._reconcile_all() + + def _drop_device_cell_loopbacks(self, nodes): + """Tear down the cell loopbacks of capture nodes that REAPPEARED. + + A node that comes back — a replug, a recovery card-cycle — is a new + node wearing the old name. Its cell loopbacks were spawned with + autoconnect off and hand-linked to the corpse, so the process being + alive is precisely the failure: it runs, it is healthy, and it + carries nothing, which reads as "my microphone stopped working" with + no visible cause. Killing them here lets the reconcile that follows + respawn and relink against the reincarnation. + """ + if not nodes: + return + with self._lock: + sids = [sid for sid, source in self._sources.items() + if source.get("node_name") in nodes] + mix_ids = list(self._mixes) + for sid in sids: + for mid in mix_ids: + self._destroy_loopback((sid, mid)) + def request_capture_poll(self): """Re-snapshot capture devices on the worker, reconciling if it moved. @@ -1485,7 +1512,7 @@ def request_capture_poll(self): def _do_poll_capture_devices(self): added, removed = self._refresh_live_captures() if added or removed: - self._reconcile_all() + self._on_captures_moved(frozenset(added)) def capture_device_present(self, node_name): """True if `node_name` is a capture device PipeWire currently has. From def9584f004fbde0331409909fab8a2a25635ffb Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:42:45 -0500 Subject: [PATCH 77/99] Explain installing on Bazzite and the other Atomic images 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- CHANGELOG.md | 4 ++ README.md | 8 ++++ docs/install-bazzite.md | 83 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 docs/install-bazzite.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f3675eb..08b032d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ versions are git tags (see [Releases](../../releases)). pair with USB handles by serial (node-stem fallback when a serial will not read), so two units of one model each follow their own row, and only state *changes* propagate, so the pair cannot loop. +- **Bazzite / Fedora Atomic install guide** (`docs/install-bazzite.md`): + checkout-first, what layering is actually needed for, why the sandbox + and the immutable `/usr` change nothing for udev or the user service, + and the manual udev step for the Flatpak path. - **Experimental Flatpak manifest** (`packaging/flatpak/`): the control panel and matrix in a sandbox, driving the host PipeWire; udev rules, first-run setup and the capture-fix daemon remain native-only. diff --git a/README.md b/README.md index d3d61bd..c264d1c 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,14 @@ them declaratively: services.udev.packages = [ openwave ]; ``` +### Bazzite / Fedora Atomic + +Immutable images change what "install" means — see +[docs/install-bazzite.md](docs/install-bazzite.md). Short version: run +from a checkout (no build step, first-run setup works as-is since `/etc` +is writable and the service is a user unit), layering only PyGObject if +the image lacks it. + ### Flatpak (experimental) A manifest lives at diff --git a/docs/install-bazzite.md b/docs/install-bazzite.md new file mode 100644 index 0000000..58868e1 --- /dev/null +++ b/docs/install-bazzite.md @@ -0,0 +1,83 @@ +# Installing on Bazzite (and other Fedora Atomic systems) + +Bazzite's `/usr` is an immutable OSTree image, which changes what "install" +means: `make install` cannot put the module into the system +`site-packages`, and layering packages with `rpm-ostree` costs a reboot per +change. What still works exactly as designed: `/etc` is writable, so the +first-run udev setup succeeds; systemd user units live in your home, so the +capture-fix service installs normally; and PipeWire, WirePlumber and their +CLI tools ship in the base image. + +Written for Bazzite; applies equally to Silverblue, Kinoite and other +uBlue images. Not yet CI-tested on an Atomic system — reports welcome +([Reporting problems](../README.md#reporting-problems)). + +## Recommended: run from a checkout + +OpenWave has no build step and no Python dependencies beyond PyGObject, so +the checkout IS the install. + +1. Check what the base image already has: + + ```bash + rpm -q python3-gobject gtk4 libadwaita libusb1 alsa-utils pipewire-utils + ``` + + On Bazzite's GNOME images everything is usually present; KDE images may + lack `python3-gobject` or `libadwaita`. Layer whatever is missing + (one reboot): + + ```bash + rpm-ostree install python3-gobject libadwaita + systemctl reboot + ``` + +2. Clone and run: + + ```bash + git clone https://github.com/NyleGarcia/openwave.git ~/openwave + cd ~/openwave && python3 -m wavexlr + ``` + +3. Let the first-run setup do its work. Both halves function on Atomic: + the udev rules go to `/etc/udev/rules.d/` (writable, via pkexec) and + the audio service is a **user** unit under `~/.config/systemd/user/`. + +4. The app writes its own drawer entry and autostart file on launch, so + after the first run it behaves like any installed application — the + `Exec` line records where the checkout lives. Updating is `git pull`. + +Do not run OpenWave from inside a distrobox: it needs the host's PipeWire +tools, ALSA cards and raw USB access, and a container adds three seams +that can each fail silently. + +## Alternative: Flatpak (experimental) + +The [manifest](../packaging/flatpak/com.github.openwave.yml) builds and +installs without touching the OS image — the most Bazzite-native shape — +but the sandbox cannot install udev rules or the capture-fix daemon, so +USB access needs one manual step: + +```bash +sudo tee /etc/udev/rules.d/99-openwave.rules >/dev/null <<'EOF' +SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="007d", MODE="0666" +SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="00a6", MODE="0666" +SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="0070", MODE="0666" +EOF +sudo udevadm control --reload && sudo udevadm trigger +``` + +Then build as the README's [Flatpak section](../README.md#flatpak-experimental) +describes. Without the daemon, hardware that hits the UAC1 firmware race +has no keepalive — if your microphone goes silent after the machine sits +idle, that is what the native daemon exists for, and the checkout install +above is the answer. + +## What not to do + +- `sudo make install` — the default `PREFIX=/usr/local` half-works + (OSTree maps it to `/var/usrlocal`), but `SITEPKG` resolves into the + read-only `/usr/lib/python3.*/site-packages` and the install fails + there. The checkout install needs none of it. +- `rpm-ostree install` of OpenWave itself — there is no RPM; layering is + only for the PyGObject/libadwaita dependencies. From 92f930f740bbaa87a82fecc41c888ce8491ef921 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:49:11 -0500 Subject: [PATCH 78/99] Point the metainfo screenshot at main, which now has it 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- com.github.openwave.metainfo.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/com.github.openwave.metainfo.xml b/com.github.openwave.metainfo.xml index ef3147a..26ae62e 100644 --- a/com.github.openwave.metainfo.xml +++ b/com.github.openwave.metainfo.xml @@ -26,7 +26,7 @@ The mixing matrix with grouped microphones and three mixes - https://raw.githubusercontent.com/NyleGarcia/openwave/v1.1.0/docs/screenshot.png + https://raw.githubusercontent.com/NyleGarcia/openwave/main/docs/screenshot.png From a9e93ee159a57a49b65e48e9f23acef4bf28b567 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:51:59 -0500 Subject: [PATCH 79/99] Ship an rpm with every release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- .github/workflows/release.yml | 17 +++++++++- packaging/rpm/openwave.spec | 62 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 packaging/rpm/openwave.spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1774cd..ba745d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,8 +97,22 @@ jobs: EOF dpkg-deb --build --root-owner-group "$PKG" + # noarch rpm from the spec: module under /usr/share/openwave with + # PYTHONPATH launchers, so one rpm serves every Fedora python. + - name: RPM package + run: | + V="${{ steps.version.outputs.version }}" + sudo apt-get install -y -qq rpm + mkdir -p rpmbuild/{SOURCES,SPECS} + cp "openwave-${V}.tar.gz" rpmbuild/SOURCES/ + sed "s/@VERSION@/${V}/" packaging/rpm/openwave.spec \ + > rpmbuild/SPECS/openwave.spec + rpmbuild --define "_topdir $PWD/rpmbuild" \ + --define "dist %{nil}" -bb rpmbuild/SPECS/openwave.spec + cp rpmbuild/RPMS/noarch/openwave-*.rpm . + - name: Checksums - run: sha256sum openwave-*.tar.gz openwave_*.deb > sha256sums.txt + run: sha256sum openwave-*.tar.gz openwave_*.deb openwave-*.rpm > sha256sums.txt - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -107,6 +121,7 @@ jobs: path: | openwave-*.tar.gz openwave_*.deb + openwave-*.rpm sha256sums.txt publish: diff --git a/packaging/rpm/openwave.spec b/packaging/rpm/openwave.spec new file mode 100644 index 0000000..ed652bc --- /dev/null +++ b/packaging/rpm/openwave.spec @@ -0,0 +1,62 @@ +# Built by release.yml, which substitutes @VERSION@ from the tag and +# feeds the release tarball in as Source0. noarch: pure Python. +# +# The module tree deliberately does NOT go into %{python3_sitelib}: this +# 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. +# Instead the tree lives under /usr/share/openwave and the two launchers +# carry PYTHONPATH — the same shape the Nix package uses for the same +# reason. + +Name: openwave +Version: @VERSION@ +Release: 1%{?dist} +Summary: The audio mixing matrix for Linux +License: MIT +URL: https://github.com/NyleGarcia/openwave +BuildArch: noarch +Source0: openwave-%{version}.tar.gz + +Requires: python3 >= 3.10 +Requires: python3-gobject +Requires: gtk4 +Requires: libadwaita +Requires: libusb1 +Requires: pipewire-utils +Requires: wireplumber +Requires: alsa-utils +Recommends: python3-xlib + +%description +Per-app mixes with per-mix outputs, plus native control of Elgato Wave +hardware - the Wave XLR interface (original and MK.2/XLR Dock) and the +Wave:3 microphone. A reverse-engineered replacement for Elgato Wave +Link, built with GTK4 and libadwaita on PipeWire. + +%prep +%autosetup + +%install +make install DESTDIR=%{buildroot} PREFIX=/usr PYTHON=python3 \ + SITEPKG=/usr/share/openwave/site-packages +# The generated launchers assume the module is importable; put the +# install's own tree on the path. +sed -i 's|exec python3|exec env PYTHONPATH=/usr/share/openwave/site-packages python3|' \ + %{buildroot}/usr/bin/openwave %{buildroot}/usr/bin/openwave-daemon + +%files +%license LICENSE +/usr/bin/openwave +/usr/bin/openwave-daemon +/usr/share/openwave/ +/usr/share/applications/openwave.desktop +/usr/share/metainfo/com.github.openwave.metainfo.xml +/usr/share/icons/hicolor/scalable/apps/openwave.svg +/usr/share/icons/hicolor/symbolic/apps/openwave-symbolic.svg +/usr/share/icons/hicolor/symbolic/apps/openwave-muted-symbolic.svg +/usr/share/icons/hicolor/symbolic/apps/openwave-attention-symbolic.svg +/usr/share/doc/openwave/ +/usr/share/licenses/openwave/ + +%changelog +# Release notes live in CHANGELOG.md and the GitHub Releases page. From 2a5dcd3ac78dc048a62f7f62903b645aa39b8870 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 15:54:03 -0500 Subject: [PATCH 80/99] Keep rpm's Version legal on dry-run builds A dispatch dry-run versions itself 0.0.0-dev., 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- .github/workflows/release.yml | 5 +++-- packaging/rpm/openwave.spec | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba745d4..b01d107 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,11 +102,12 @@ jobs: - name: RPM package run: | V="${{ steps.version.outputs.version }}" + RV="${V//-/\~}" # rpm Version forbids '-'; ~ is its pre-release sudo apt-get install -y -qq rpm mkdir -p rpmbuild/{SOURCES,SPECS} cp "openwave-${V}.tar.gz" rpmbuild/SOURCES/ - sed "s/@VERSION@/${V}/" packaging/rpm/openwave.spec \ - > rpmbuild/SPECS/openwave.spec + sed -e "s/@VERSION@/${RV}/" -e "s/@SRCVER@/${V}/" \ + packaging/rpm/openwave.spec > rpmbuild/SPECS/openwave.spec rpmbuild --define "_topdir $PWD/rpmbuild" \ --define "dist %{nil}" -bb rpmbuild/SPECS/openwave.spec cp rpmbuild/RPMS/noarch/openwave-*.rpm . diff --git a/packaging/rpm/openwave.spec b/packaging/rpm/openwave.spec index ed652bc..0a12672 100644 --- a/packaging/rpm/openwave.spec +++ b/packaging/rpm/openwave.spec @@ -15,7 +15,9 @@ Summary: The audio mixing matrix for Linux License: MIT URL: https://github.com/NyleGarcia/openwave BuildArch: noarch -Source0: openwave-%{version}.tar.gz +# @SRCVER@ is the tarball's own version string, which for a dispatch +# dry-run contains characters (0.0.0-dev.) rpm's Version cannot. +Source0: openwave-@SRCVER@.tar.gz Requires: python3 >= 3.10 Requires: python3-gobject @@ -34,7 +36,7 @@ Wave:3 microphone. A reverse-engineered replacement for Elgato Wave Link, built with GTK4 and libadwaita on PipeWire. %prep -%autosetup +%autosetup -n openwave-@SRCVER@ %install make install DESTDIR=%{buildroot} PREFIX=/usr PYTHON=python3 \ From fb7efe68143cbe62ab6127bd9ce735fe84ea05eb Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 16:05:00 -0500 Subject: [PATCH 81/99] Make the Flatpak real: it builds, boots, and knows it is sandboxed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- CHANGELOG.md | 13 +++-- packaging/flatpak/com.github.openwave.yml | 65 +++++++++++++++++++++-- wavexlr/setup.py | 16 ++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b032d..2ac38e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,16 @@ versions are git tags (see [Releases](../../releases)). checkout-first, what layering is actually needed for, why the sandbox and the immutable `/usr` change nothing for udev or the user service, and the manual udev step for the Flatpak path. -- **Experimental Flatpak manifest** (`packaging/flatpak/`): the control - panel and matrix in a sandbox, driving the host PipeWire; udev rules, - first-run setup and the capture-fix daemon remain native-only. +- **An rpm with every release**: noarch, built by the release workflow, + module under /usr/share/openwave with PYTHONPATH launchers so one + package serves every Fedora python. Validated end-to-end by a CI + dry-run. +- **Flatpak manifest** (`packaging/flatpak/`), built and smoke-tested: + GNOME 49 runtime, bundling libusb, alsa-lib/utils, the PipeWire tools, + Lua and WirePlumber's wpctl; the app boots sandboxed with its full + D-Bus surface. First-run setup knows it is sandboxed and points at the + host udev step instead of crashing into a pkexec that is not there; + the capture-fix daemon remains native-only. - **Devices are discovered while running**: a Wave plugged in mid-session — or plugged back in after its row was removed — gets its row within seconds instead of on the next launch. diff --git a/packaging/flatpak/com.github.openwave.yml b/packaging/flatpak/com.github.openwave.yml index 54e13a3..2b13741 100644 --- a/packaging/flatpak/com.github.openwave.yml +++ b/packaging/flatpak/com.github.openwave.yml @@ -25,7 +25,7 @@ app-id: com.github.openwave runtime: org.gnome.Platform -runtime-version: "48" +runtime-version: "49" sdk: org.gnome.Sdk command: openwave @@ -38,8 +38,6 @@ finish-args: # The host PipeWire graph is the whole point. - --filesystem=xdg-run/pipewire-0 - --socket=pulseaudio - # ALSA card topology (amixer numids, usbid matching). - - --filesystem=/proc/asound:ro # Generated PipeWire/WirePlumber user config, autostart + drawer entries. - --filesystem=xdg-config/pipewire:create - --filesystem=xdg-config/wireplumber:create @@ -49,6 +47,31 @@ finish-args: - --talk-name=org.kde.StatusNotifierWatcher modules: + # The GNOME runtime ships no libusb, and raw libusb IS the device layer. + - name: libusb + sources: + - type: git + url: https://github.com/libusb/libusb.git + tag: v1.0.29 + commit: 15a7ebb4d426c5ce196684347d2b7cafad862626 + - type: shell + commands: + - autoreconf -fiv + config-opts: + - --disable-udev + + # alsa-utils insists on a libasound at least its own version, and the + # runtime's is older — build the matching one first. + - name: alsa-lib + sources: + - type: git + url: https://github.com/alsa-project/alsa-lib.git + tag: v1.2.13 + commit: 785fd327ada6fc1778a2bb21176cb66705eb6b33 + - type: shell + commands: + - autoreconf -fiv + # amixer — the ALSA sync path shells out to it. - name: alsa-utils sources: @@ -56,10 +79,15 @@ modules: url: https://github.com/alsa-project/alsa-utils.git tag: v1.2.13 commit: f04b9e0f1285fe25d4146906c5a8511741b8abad + # The git tree ships gitcompile, not autogen.sh; make configure exist. + - type: shell + commands: + - autoreconf -fiv config-opts: - --disable-alsamixer - --disable-xmlto - --disable-nls + - --with-udev-rules-dir=/app/lib/udev/rules.d # pw-cat, pw-cli, pw-dump, pw-link, pw-loopback — the router's hands. - name: pipewire-tools @@ -77,6 +105,34 @@ modules: tag: "1.4.2" commit: d20a1523b6770dfa93a270bdda5d7c800d7ec191 + # wireplumber embeds Lua, the runtime has none, and offline builds + # cannot fetch its wrap subproject. + - name: lua + buildsystem: simple + build-commands: + - make -C src all CC=cc MYCFLAGS="-fPIC -DLUA_USE_LINUX" MYLIBS="-ldl" + - make install INSTALL_TOP=/app + # Lua's makefile ships no .pc, and pkg-config is how meson looks. + - | + mkdir -p /app/lib/pkgconfig + for name in lua lua-5.4 lua5.4; do + cat > /app/lib/pkgconfig/${name}.pc <<'EOF' + prefix=/app + libdir=${prefix}/lib + includedir=${prefix}/include + + Name: Lua + Description: Lua language engine + Version: 5.4.8 + Libs: -L${libdir} -llua -lm -ldl + Cflags: -I${includedir} + EOF + done + sources: + - type: archive + url: https://www.lua.org/ftp/lua-5.4.8.tar.gz + sha256: 4f18ddae154e793e46eeab727c59ef1c0c0c2b744e7b94219710d76f530629ae + # wpctl — volumes are written through it. - name: wireplumber buildsystem: meson @@ -85,7 +141,8 @@ modules: - -Delogind=disabled - -Ddoc=disabled - -Dintrospection=disabled - - -Dtests=disabled + - -Dtests=false + - -Dsystem-lua=true sources: - type: git url: https://gitlab.freedesktop.org/pipewire/wireplumber.git diff --git a/wavexlr/setup.py b/wavexlr/setup.py index 91473dd..b563140 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -21,6 +21,12 @@ UDEV_PATH = "/etc/udev/rules.d/99-openwave.rules" UDEV_PATH_OLD = "/etc/udev/rules.d/99-wavexlr.rules" +# Inside a Flatpak sandbox there is no pkexec, no host /etc to read or +# write, and no way to install anything system-side. First-run setup must +# say so instead of crashing into the missing binary; the manifest's docs +# carry the manual udev step. +IN_FLATPAK = os.path.exists("/.flatpak-info") + WIREPLUMBER_NAME = "51-openwave-wave-xlr.conf" WIREPLUMBER_PATH = os.path.expanduser( "~/.config/wireplumber/wireplumber.conf.d/" + WIREPLUMBER_NAME @@ -41,6 +47,12 @@ def mixes_source(): def udev_installed(): + # The sandbox can neither read the host's rules nor install them, so + # the only non-crashing answers are "assume yes" and a permanent + # re-prompt for a setup that cannot run. Assume yes; a device that + # actually lacks the rule fails to open and the docs cover the fix. + if IN_FLATPAK: + return True for path in (UDEV_PATH, UDEV_PATH_OLD): try: with open(path) as f: @@ -379,6 +391,10 @@ def _install_mixes_locked(mixes): def run_setup(): """Run full first-time setup. Returns (success, message).""" + if IN_FLATPAK: + return False, ("Setup cannot run inside the Flatpak sandbox. " + "Install the udev rules once on the host — see the " + "Flatpak section of the README.") messages = [] if not udev_installed(): From 2b982208592d150123e4ffbfe76d9cd8cc3c64fb Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 16:08:00 -0500 Subject: [PATCH 82/99] Plan the DSP chain around what is proven, park NVIDIA behind questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- plans/next/dsp-chain.md | 110 ++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 32 deletions(-) diff --git a/plans/next/dsp-chain.md b/plans/next/dsp-chain.md index 22fb37d..da1d7c8 100644 --- a/plans/next/dsp-chain.md +++ b/plans/next/dsp-chain.md @@ -1,37 +1,83 @@ -# Next: Host-side DSP chain - -Gap: openxlr offers host-side DSP (high-pass 80/120 Hz, ClipGuard-style -limiter, compressor/expander via LADSPA) for devices without onboard -effects; Wave Link has the full VST/AU stack. OpenWave has none -(`docs/comparison.md`, "Hardware control"). - -Parked in `next/` because it is the largest item and Phase 1–3 of -`plans/now/todo.md` should land first. Promote by moving this file's task -list into `plans/now/todo.md` and expanding into a full spec in -`plans/specs/`. - -## Shape (to be spec'd before promotion) - -- **Mechanism**: `libpipewire-module-filter-chain` node inserted between a - microphone capture row and its cells — fits the existing architecture - (ordinary PipeWire objects, no custom audio code). LADSPA `swh-plugins` - as optional dependency, exactly openxlr's approach; builtin filter-chain - plugins (`bq_highpass` etc.) cover the high-pass with zero new deps. -- **v1 scope**: per-microphone-row toggle set — high-pass (80/120 Hz), - hard limiter at −3 dB ("clip guard"). Compressor/expander later. -- **Where it lives**: filter-chain config generated like the mix sinks - (`setup.py` render path), node named `openwave_fx_`, reconciled - by `Mixer` like any other node; per-row FX popover in the matrix UI. -- **Persistence**: per-source FX settings in `sources.json` (they are - source identity, like trim). -- **Testing**: reconcile decisions against FakePipeWire (spawn/despawn/ - relink when FX toggles); the audible result is hardware-verified like - the rest of the routing. +# Next: Per-microphone DSP chain (low cut, gate, noise removal) + +Gap: openxlr offers host-side DSP for devices without onboard effects; +Wave Link has the full VST/AU stack; OpenWave has none. User ask on top: +AI noise removal — "NVIDIA Broadcast"-class — plus the classics (low cut, +gate). + +## Architecture (fixed) + +One `libpipewire-module-filter-chain` node per microphone row, inserted +between the capture node and its cells — the same shape as everything +else in the router: ordinary PipeWire objects, no custom audio code. +Node named `openwave_fx_`, `priority.session=0` and the naming +sweep like intake sinks, reconciled by `Mixer`, settings persisted on the +source record in `sources.json` (they are source identity, like trim). +Cells capture the FX node instead of the raw device when any effect is +on; the replug self-heal applies to the FX node the same way. + +## Effect tiers, in build order + +### Tier 1 — builtin biquads (zero new dependencies) +- **Low cut / high-pass** at 80 or 120 Hz (`bq_highpass`). +- Ships first: proves the insertion, the UI, the persistence and the + reconcile with nothing to install. + +### Tier 2 — LADSPA classics (optional dependency: swh-plugins) +- **Gate** (swh), **compressor** (swh sc4), **hard limiter** at −3 dB + (ClipGuard-alike). Degrade visibly when the plugin library is absent — + a toggle that says "install swh-plugins", never a silent no-op. + +### Tier 3 — AI noise removal (optional dependency: RNNoise plugin) +- [werman/noise-suppression-for-voice](https://github.com/werman/noise-suppression-for-voice): + `librnnoise_ladspa.so`, `noise_suppressor_mono` — VERIFIED: PipeWire's + own docs carry a filter-chain config for exactly this since 0.3.45, + GPL-3.0, 48 kHz native which is what the graph runs. CPU-only, no GPU + requirement, packaged in most distros (AUR/Fedora/Debian). This is the + default "noise removal" toggle. +- The VAD grace-period knob is the one setting worth exposing (word + onsets vs latency). + +### Tier 4 — NVIDIA Maxine/Broadcast AFX (research track, promoted only +if it earns it) +Not scheduled; open questions to answer before any code: +1. Current Linux availability of the Audio Effects SDK and its license — + the public page gates behind a 90-day trial and says nothing about + redistribution. OpenWave could at most dlopen a user-installed SDK, + never ship it. +2. RTX-only + TensorRT runtime: a hard hardware wall RNNoise does not + have. +3. No PipeWire story exists: integration means writing a filter-chain + plugin (filter-chain loads LADSPA — a LADSPA shim around the SDK's + streaming API is the plausible shape) or a standalone + consume/produce node. Real project either way. +4. Whether its denoise/dereverb beats RNNoise enough, on this hardware, + to justify 1–3. Decide with recordings, not marketing. + +Verdict for now: Tier 3 gives the "Broadcast" experience with none of +the walls; Tier 4 stays parked until someone measures a quality gap. + +## UI + +Per-microphone-row FX popover (next to the mute): toggles for low cut +(80/120), gate, compressor, limiter, noise removal; a "plugin missing" +state that names the package. Scenes capture FX settings with the rest +of the source record — free, since they live on it. ## Risks | Risk | Impact | Mitigation | |---|---|---| -| Latency added in the mic path | High | builtin biquads first; measure with `pw-top` before/after; FX off by default | -| filter-chain node caught by default-sink election / claiming | Med | same `priority.session=0` + naming-sweep treatment as intake sinks | -| swh-plugins missing at runtime | Low | builtin-only v1; LADSPA features degrade with a visible "plugin missing" state | +| Latency in the mic path | High | builtin biquads near-zero; RNNoise adds ~10 ms frame + optional VAD grace — show it, default modest; FX off by default | +| FX node caught by default-sink election / claiming | Med | same priority.session=0 + sweep treatment as intake sinks | +| Missing plugin libraries | Low | visible degraded state naming the package; Tier 1 always works | +| RNNoise misclassifies poor mics | Low | it is a toggle; meters make the effect audible AND visible | + +## Promotion checklist (next → now) + +- [ ] Tier 1 spec'd into tasks (filter-chain render, mixer insertion, + popover, persistence, reconcile tests against FakePipeWire) +- [ ] swh-plugins + rnnoise plugin packaging notes per distro (incl. + the Flatpak manifest additions — both are LADSPA .so files the + sandbox must bundle) +- [ ] Latency measured on real hardware before defaults are chosen From d07007e8b735dfa2b703c16beb5c6e24b3612ca0 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 16:11:20 -0500 Subject: [PATCH 83/99] Grow the DSP plan to the full chain the matrix deserves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- plans/next/dsp-chain.md | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/plans/next/dsp-chain.md b/plans/next/dsp-chain.md index da1d7c8..f970e04 100644 --- a/plans/next/dsp-chain.md +++ b/plans/next/dsp-chain.md @@ -38,6 +38,42 @@ on; the replug self-heal applies to the FX node the same way. - The VAD grace-period knob is the one setting worth exposing (word onsets vs latency). +### Tier 1b — more builtins (still zero dependencies) +- **Presence EQ**: three bands from builtin biquads (`bq_lowshelf`, + `bq_peaking`, `bq_highshelf`) — broadcast-voice tone shaping. +- **Mono downmix toggle**: a stereo capture forced to centered mono + (channel-mix in the FX node) — the fix for one-sided interfaces. +- **Per-source delay**: millisecond alignment so mic and desktop audio + hit the Record/Stream mix in sync (builtin delay). Lives on any + source, not just microphones. + +### Tier 2b — more LADSPA classics +- **De-esser** (TAP `tap_deesser`) — candidate plugin, verify at build. +- **Auto-leveler / AGC** — slow-attack leveling so nobody rides gain. + Candidate: sc4 with leveler settings or TAP AGC; pick by ear at build + time, and say which plugin the toggle needs. + +### Mix-side chain (separate insertion point: before a mix's output +loopback, or app-driven) +- **Headphone EQ per mix**: biquad EQ on the *output* path (Personal Mix + → Arctis), AutoEq-style curves importable later. Same filter-chain + mechanics, different insertion point. +- **Music ducking**: music dips when the microphone is live. Two + implementation shapes, decided at build: (a) LADSPA sidechain + compressor — blocked on filter-chain's single-capture-stream model, + probably a dead end; (b) **app-driven**: the per-source meters already + produce a 15 Hz voice envelope, and cell volumes are already written + through the throttler — ducking is a small control loop over machinery + that exists (watch mic meter, ease the Music cell down/up). (b) is the + recommendation: no DSP at all, scene-aware, and the release curve is + a Python constant instead of a plugin parameter. +- **Loudness meter (LUFS)**: EBU R128 readout on the Record/Stream mix + so streams land near −14/−16 LUFS. Metering only. Needs a 48 kHz tap + (the 8 kHz level meters cannot carry K-weighting); spawn it only while + the readout is visible. K-weighting is two fixed biquads — pure + Python over the existing meter-reader pattern, `libebur128` optional + if the numbers disagree with OBS. + ### Tier 4 — NVIDIA Maxine/Broadcast AFX (research track, promoted only if it earns it) Not scheduled; open questions to answer before any code: @@ -73,6 +109,17 @@ of the source record — free, since they live on it. | Missing plugin libraries | Low | visible degraded state naming the package; Tier 1 always works | | RNNoise misclassifies poor mics | Low | it is a toggle; meters make the effect audible AND visible | +## Suggested build order across it all + +1. Tier 1 low cut (proves the insertion end to end) +2. Tier 1b EQ + mono + delay (same node, zero deps, big visible win) +3. Tier 2 gate/comp/limiter, then 2b de-esser/AGC +4. Tier 3 RNNoise +5. Ducking (app-driven) and LUFS meter — independent of the FX node, + can land any time after the meters exist (they do) +6. Headphone EQ per mix +7. Tier 4 NVIDIA — only past its questions + ## Promotion checklist (next → now) - [ ] Tier 1 spec'd into tasks (filter-chain render, mixer insertion, From 2f9bcece7289cab9276dc8f7b7fd87855d65ad90 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 19:30:34 -0500 Subject: [PATCH 84/99] Give every microphone row a DSP chain built from builtins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- CHANGELOG.md | 8 ++ README.md | 6 ++ tests/support.py | 2 + tests/test_fx.py | 110 ++++++++++++++++++++++++++ wavexlr/app.py | 34 ++++++++- wavexlr/mixer.py | 178 ++++++++++++++++++++++++++++++++++++++++++- wavexlr/mixmatrix.py | 112 +++++++++++++++++++++++++++ wavexlr/sources.py | 34 +++++++++ 8 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 tests/test_fx.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac38e1..e6e9bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ versions are git tags (see [Releases](../../releases)). connected, the capture-fix daemon keeps one keepalive pin per device, scenes record hardware state per serial number, and the tray reports muted when any device's hardware mute is down. +- **Per-microphone DSP chain**: every capture row gains an effects + popover — low cut (80/120 Hz), three-band presence EQ, alignment + delay up to 500 ms, and forced mono — built from PipeWire's builtin + filter-chain plugins, zero new dependencies. Each active chain is one + `pipewire -c` child publishing a virtual Source the row's cells drink + from; neutral settings hold no process at all. Verified on hardware: + a 120 Hz low cut measured 10 dB of relative low-end removal at the + chain's output. - **Mix master sliders and output meters**: every mix column header now carries its master volume slider (throttled, and following external moves — pavucontrol, media keys, scenes — within a couple of seconds) diff --git a/README.md b/README.md index c264d1c..3bb23e1 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ all, so on that hardware the app is the only way to switch it. - **System integration** — mute and volumes sync bidirectionally with PipeWire/ALSA, with ALSA controls discovered by name so a firmware revision that renumbers them cannot break it. +- **Per-microphone effects** — each capture row carries a DSP popover: + low cut (80/120 Hz), three-band tone EQ, alignment delay (sync your + mic to desktop audio in a recording), and forced mono. Built from + PipeWire's own filter-chain — nothing to install, no process running + while everything is neutral. Gate, compressor and AI noise removal are + on the roadmap as optional plugins. - **Hotplug** — a Wave plugged in after launch is picked up automatically. - **Multiple devices** — every connected Wave is opened, polled and ALSA-synced at once, two of the same model included (told apart by USB diff --git a/tests/support.py b/tests/support.py index 2908ad6..055f3d8 100644 --- a/tests/support.py +++ b/tests/support.py @@ -64,6 +64,7 @@ def bare_mixer(**attrs): # running. The queue is the seam: work lands in _pending and stays there, # so a test can call the real entry points and inspect the state they # persisted without a subprocess ever being spawned. + mx._fx_conf = {} mx._pending = {} mx._pending_lock = threading.Lock() mx._wake = threading.Event() @@ -143,6 +144,7 @@ def wpctl(self, *args): self.calls.append(("wpctl",) + args) def ports(self, direction_flag, node_name): + self.calls.append(("ports", direction_flag, node_name)) return self.port_map.get((direction_flag, node_name), []) def link(self, src_port, dst_port): diff --git a/tests/test_fx.py b/tests/test_fx.py new file mode 100644 index 0000000..af9f38b --- /dev/null +++ b/tests/test_fx.py @@ -0,0 +1,110 @@ +"""The per-microphone DSP chain: config render and lifecycle. + +The chain is a filter-chain hosted by a `pipewire -c ` +child. Neutral settings hold no process, cells drink from the chain's +published Source while it runs, and a settings change respawns rather +than patching — one code path. +""" + +import os +import unittest + +from wavexlr import mixer as mixer_mod +from wavexlr import sources +from .support import FakePipeWire, bare_mixer, temp_config + +ARCTIS = "alsa_input.usb-Arctis-00.mono-fallback" + + +def _dev_source(**fx): + return {"id": "dock", "kind": sources.KIND_DEVICE, "name": "Dock", + "node_name": ARCTIS, "level": 1.0, "fx": fx} + + +class RenderConfig(unittest.TestCase): + def test_each_effect_contributes_its_node(self): + conf = mixer_mod.render_fx_config(_dev_source( + lowcut=80, eq_low=3.0, eq_mid=-2.0, eq_high=1.5, delay_ms=120)) + for label in ("bq_highpass", "bq_lowshelf", "bq_peaking", + "bq_highshelf", "delay"): + self.assertIn(label, conf) + self.assertIn('"Freq" = 80.0', conf) + self.assertIn('"Delay (s)" = 0.1200', conf) + # sequential chain: every adjacent pair is linked + self.assertIn('output = "hp:Out" input = "eql:In"', conf) + + def test_neutral_plus_mono_is_a_bare_copy(self): + conf = mixer_mod.render_fx_config(_dev_source(mono=True)) + self.assertIn("label = copy", conf) + self.assertNotIn("bq_", conf) + + def test_chain_captures_the_raw_device_and_publishes_a_source(self): + conf = mixer_mod.render_fx_config(_dev_source(lowcut=120)) + self.assertIn(f'target.object = "{ARCTIS}"', conf) + self.assertIn(f'node.name = "{mixer_mod.fx_node_name("dock")}"', conf) + self.assertIn("media.class = Audio/Source", conf) + + +class Lifecycle(unittest.TestCase): + def setUp(self): + self._ctx = temp_config() + self._ctx.__enter__() + self.addCleanup(self._ctx.__exit__, None, None, None) + self.pw = FakePipeWire() + self.mx = bare_mixer(_pw=self.pw) + self.mx._sources = {"dock": _dev_source(lowcut=80)} + self.mx._live_captures = frozenset({ARCTIS}) + + def _fx_procs(self): + return [p for p in self.pw.spawned if p.argv[0] == "pipewire"] + + def test_active_fx_spawns_the_chain_and_writes_its_config(self): + self.mx._reconcile_fx("dock") + procs = self._fx_procs() + self.assertEqual(len(procs), 1) + path = procs[0].argv[2] + self.assertTrue(os.path.exists(path)) + self.assertIn("bq_highpass", open(path).read()) + + def test_neutral_fx_holds_no_process(self): + self.mx._reconcile_fx("dock") + self.mx._sources["dock"]["fx"] = {} + self.mx._reconcile_fx("dock") + self.assertTrue(self._fx_procs()[0].terminated) + + def test_unchanged_settings_do_not_respawn(self): + self.mx._reconcile_fx("dock") + self.mx._reconcile_fx("dock") + self.assertEqual(len(self._fx_procs()), 1) + + def test_changed_settings_respawn(self): + self.mx._reconcile_fx("dock") + self.mx._sources["dock"]["fx"] = {"lowcut": 120} + self.mx._reconcile_fx("dock") + procs = self._fx_procs() + self.assertEqual(len(procs), 2) + self.assertTrue(procs[0].terminated) + + def test_cells_drink_from_the_chain_while_it_runs(self): + self.mx._mixes = {"chat": {"id": "chat", "name": "Chat", + "sink": "openwave_chat_mix"}} + self.mx._state = {"dock.chat": {"volume": 0.8, "muted": False}} + self.mx._reconcile_fx("dock") + self.mx._reconcile_cell("dock", "chat") + fx_node = mixer_mod.fx_node_name("dock") + self.assertIn(("ports", "-o", fx_node), self.pw.calls, + "the cell loopback must link from the fx node, " + "not the raw device") + self.assertNotIn(("ports", "-o", ARCTIS), self.pw.calls) + + def test_replug_tears_the_chain_down_for_respawn(self): + self.mx._reconcile_fx("dock") + first = self._fx_procs()[0] + self.mx._drop_device_cell_loopbacks(frozenset({ARCTIS})) + self.assertTrue(first.terminated) + self.mx._reconcile_fx("dock") + self.assertEqual(len(self._fx_procs()), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index fde418d..8d77fc8 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -80,6 +80,7 @@ def __init__(self, **kwargs): # Debounce slider events to coalesce a flurry of value-changed signals # during a drag into one set_cell. {(source_id, mix_id): timeout_id}. self._cell_debounce_ids = {} + self._fx_debounce_ids = {} # One-shot re-read of the routing after a mix output change settles. self._output_refresh_id = None self._sources = sources_module.load_seeded() @@ -1659,8 +1660,12 @@ def _wire_source_row(self, source_id): return # Protected device rows carry a remove button that starts hidden; # the presence tick shows it only while the device is unplugged. - if sources_module.is_protected(self._sources.get(source_id, {})): + source = self._sources.get(source_id, {}) + if sources_module.is_protected(source): cell.set_removable(False) + if sources_module.kind(source) == sources_module.KIND_DEVICE: + cell.set_fx(sources_module.fx(source)) + cell.connect("fx-changed", self._on_source_fx_changed, source_id) source = self._sources.get(source_id, {}) cell.set_volume(float(source.get("level", 1.0))) cell.set_muted(bool(source.get("muted", False))) @@ -1673,6 +1678,33 @@ def _on_source_level_changed(self, _cell, volume, source_id): source_id, volume, self._sources.get(source_id, {}).get("muted", False)) sources_module.save(self._sources) + _FX_DEBOUNCE_MS = 400 + + def _on_source_fx_changed(self, cell, source_id): + """Persist fx edits and respawn the chain, debounced per source. + + Every popover gesture emits; a drag across an EQ scale is dozens + of emissions, and each respawn restarts a process. The store is + written when the timer fires, so a crash mid-drag loses 400 ms of + slider, not the chain. + """ + prev = self._fx_debounce_ids.pop(source_id, None) + if prev is not None: + GLib.source_remove(prev) + + def _apply(sid=source_id, c=cell): + self._fx_debounce_ids.pop(sid, None) + source = self._sources.get(sid) + if source is None: + return GLib.SOURCE_REMOVE + source["fx"] = c.fx_settings() + sources_module.save(self._sources) + self.mixer.set_sources(self._sources) + return GLib.SOURCE_REMOVE + + self._fx_debounce_ids[source_id] = GLib.timeout_add( + self._FX_DEBOUNCE_MS, _apply) + def _on_source_mute_toggled(self, _cell, muted, source_id): self.mixer.set_source_level( source_id, self._sources.get(source_id, {}).get("level", 1.0), muted) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 1600efb..9f0576b 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -65,6 +65,123 @@ def friendly_device_name(description): SOURCE_SINK_PREFIX = "openwave_src_" +FX_NODE_PREFIX = "openwave_fx_" + + +def fx_node_name(source_id): + """The virtual Source a microphone's DSP chain publishes. + + When any effect is on, the row's cells capture this node instead of + the raw device — the same virtual-microphone shape PipeWire's own + filter-chain documentation uses. + """ + return f"{FX_NODE_PREFIX}{source_id}" + + +def fx_config_path(source_id): + """Where a source's generated filter-chain config lives. + + Beside the cell store on purpose: the tests redirect CONFIG_PATH into + a sandbox, and the configs must follow it there. + """ + return os.path.join(os.path.dirname(CONFIG_PATH), "fx", + f"{source_id}.conf") + + +def render_fx_config(source): + """A pipewire.conf that hosts one filter-chain for this source's fx. + + The chain is built only from what is non-neutral, in fixed order: + high-pass, then the three tone bands, then delay. A mono-only chain + is a single copy node — the downmix is the streams being one channel, + not a plugin. Spawned as `pipewire -c `: a module must live in + some process, and a config-owned child fits the same lifecycle rules + as every pw-loopback here. + """ + f = sources.fx(source) + nodes, controls = [], [] + if f["lowcut"]: + nodes.append(("hp", "bq_highpass", + f'control = {{ "Freq" = {float(f["lowcut"]):.1f} }}')) + if f["eq_low"]: + nodes.append(("eql", "bq_lowshelf", + f'control = {{ "Freq" = 100.0 "Gain" = {float(f["eq_low"]):.1f} }}')) + if f["eq_mid"]: + nodes.append(("eqm", "bq_peaking", + f'control = {{ "Freq" = 1000.0 "Gain" = {float(f["eq_mid"]):.1f} }}')) + if f["eq_high"]: + nodes.append(("eqh", "bq_highshelf", + f'control = {{ "Freq" = 8000.0 "Gain" = {float(f["eq_high"]):.1f} }}')) + if f["delay_ms"]: + secs = max(0.0, min(1.0, float(f["delay_ms"]) / 1000.0)) + nodes.append(("dly", "delay", + 'config = { "max-delay" = 1.0 } ' + f'control = {{ "Delay (s)" = {secs:.4f} }}')) + if not nodes: + nodes.append(("thru", "copy", "")) + + node_lines = "\n".join( + f' {{ type = builtin name = {name} ' + f'label = {label} {extra} }}' + for name, label, extra in nodes + ) + link_lines = "\n".join( + f' {{ output = "{a[0]}:Out" input = "{b[0]}:In" }}' + for a, b in zip(nodes, nodes[1:]) + ) + label = source.get("name", source["id"]) + node = fx_node_name(source["id"]) + raw = source.get("node_name", "") + # One channel end to end: every supported microphone is mono, and for + # a stereo capture this IS the mono downmix toggle's mechanism. + # The module preamble mirrors the stock filter-chain.conf: a bare + # `pipewire -c` context has no protocol-native and cannot even connect + # to the daemon without it. + return f"""# Generated by OpenWave — one DSP chain for "{label}". Do not edit. +context.properties = {{ log.level = 2 }} +context.spa-libs = {{ + audio.convert.* = audioconvert/libspa-audioconvert + support.* = support/libspa-support +}} +context.modules = [ + {{ name = libpipewire-module-rt + args = {{ nice.level = -11 }} + flags = [ ifexists nofail ] + }} + {{ name = libpipewire-module-protocol-native }} + {{ name = libpipewire-module-client-node }} + {{ name = libpipewire-module-adapter }} + {{ name = libpipewire-module-filter-chain + args = {{ + node.description = "OpenWave FX: {label}" + media.name = "OpenWave FX: {label}" + filter.graph = {{ + nodes = [ +{node_lines} + ] +{" links = [" + chr(10) + link_lines + chr(10) + " ]" if link_lines else ""} + }} + audio.channels = 1 + audio.position = [ MONO ] + capture.props = {{ + node.name = "{node}_cap" + target.object = "{raw}" + node.passive = true + application.name = OpenWave + node.description = "OpenWave FX: {label} (capture)" + }} + playback.props = {{ + node.name = "{node}" + media.class = Audio/Source + application.name = OpenWave + node.description = "OpenWave FX: {label}" + }} + }} + }} +] +""" + + def source_sink_name(source_id): """The intake sink an application source's streams are moved onto.""" return f"{SOURCE_SINK_PREFIX}{source_id}" @@ -735,6 +852,7 @@ def __init__(self, pw=None): self._pw = pw or SubprocessPipeWire() self._lock = Lock() self._procs = {} + self._fx_conf = {} # source_id -> rendered fx config, for respawn diff self._state = self._load_state() if self._migrate_state(): self._save_state() @@ -1498,6 +1616,10 @@ def _drop_device_cell_loopbacks(self, nodes): for sid in sids: for mid in mix_ids: self._destroy_loopback((sid, mid)) + # The fx chain captured the corpse too; forget its config hash + # so the reconcile respawns it against the reincarnation. + self._destroy_loopback(self._fx_key(sid)) + self._fx_conf.pop(sid, None) def request_capture_poll(self): """Re-snapshot capture devices on the worker, reconciling if it moved. @@ -1771,10 +1893,51 @@ def _reconcile_all(self): # microphone into every mix twice. source_ids = list(self._sources) mix_ids = list(self._mixes) + for source_id in source_ids: + self._reconcile_fx(source_id) for source_id in source_ids: for mix_id in mix_ids: self._reconcile_cell(source_id, mix_id) + def _fx_key(self, source_id): + # Shaped (source_id, ...) so _do_remove_source's prefix sweep and + # the replug teardown catch it with the cell loopbacks. + return (source_id, "__fx__") + + def _reconcile_fx(self, source_id): + """Keep one filter-chain process matching the source's fx settings. + + Neutral settings hold no process. A settings change respawns — + the chain rebuilds in well under a second, and a respawn is the + one code path, where live parameter patching would be a second + one that drifts. + """ + source = self._sources.get(source_id) + key = self._fx_key(source_id) + wanted = (source is not None + and sources.kind(source) == sources.KIND_DEVICE + and sources.fx_active(source) + and bool(source.get("node_name"))) + if not wanted: + self._destroy_loopback(key) + self._fx_conf.pop(source_id, None) + return + conf = render_fx_config(source) + proc = self._procs.get(key) + if proc is not None and proc.poll() is None \ + and self._fx_conf.get(source_id) == conf: + return + self._destroy_loopback(key) + path = fx_config_path(source_id) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as handle: + handle.write(conf) + spawned = self._pw.spawn_loopback(["pipewire", "-c", path], False) + if spawned is None: + return + self._procs[key] = spawned + self._fx_conf[source_id] = conf + def _reconcile_cell(self, source_id, mix_id): state = self._state.get( f"{source_id}.{mix_id}", {"volume": 0.0, "muted": False} @@ -1784,8 +1947,15 @@ def _reconcile_cell(self, source_id, mix_id): # only ever sees a finished one. source = self._sources.get(source_id) if source is not None and sources.kind(source) == sources.KIND_DEVICE: + capture_node = source.get("node_name") + fx_proc = self._procs.get(self._fx_key(source_id)) + if sources.fx_active(source) \ + and fx_proc is not None and fx_proc.poll() is None: + # The cells drink from the DSP chain's published Source, + # not the raw device — that is the whole insertion. + capture_node = fx_node_name(source_id) self._reconcile_capture_cell( - source_id, mix_id, source.get("node_name"), + source_id, mix_id, capture_node, state["volume"], state["muted"], ) return @@ -1827,7 +1997,11 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted node_name = self._capture_loopback_name(source_id, mix_id) mix_sink = self._mix_sink(mix_id) live = self._live_captures - absent = bool(live) and capture_node not in live + # An FX chain's node is ours: its process is the presence check, + # and a freshly spawned chain must not read as "device absent" for + # the seconds until the next pw-dump snapshot sees it. + absent = (bool(live) and capture_node not in live + and not str(capture_node or "").startswith(FX_NODE_PREFIX)) if not capture_node or not mix_sink or volume <= 0.0 or absent: self._destroy_loopback(key) return diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 86a80fe..984bd83 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -773,6 +773,8 @@ class SourceCell(Gtk.Box): # Make this the live source in its group "switch-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), "edit-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), + # DSP popover moved; read the values back with fx_settings() + "fx-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), } def __init__(self, *, name, icon_name, has_level, removable=False, @@ -907,6 +909,18 @@ def __init__(self, *, name, icon_name, has_level, removable=False, self._level.add_offset_value(Gtk.LEVEL_BAR_OFFSET_FULL, 1.00) inner.append(self._level) + self._fx_widgets = None + if is_capture: + fx_btn = Gtk.MenuButton( + icon_name="preferences-color-symbolic", + valign=Gtk.Align.CENTER, + tooltip_text="Effects (low cut, EQ, delay)", + ) + fx_btn.add_css_class("flat") + fx_btn.add_css_class("circular") + fx_btn.set_popover(self._build_fx_popover()) + inner.append(fx_btn) + if editable: edit_btn = Gtk.Button( icon_name="document-edit-symbolic", @@ -931,6 +945,104 @@ def __init__(self, *, name, icon_name, has_level, removable=False, "clicked", lambda _: self.emit("remove-clicked")) inner.append(self._remove_btn) + __FX_SIGNAL = "fx-changed" + + def _build_fx_popover(self): + """The per-microphone DSP controls: low cut, tone, delay, mono. + + Widgets are the state; fx_settings() reads them and set_fx() writes + them with signals blocked, mirroring how every other control here + round-trips. Emission is per-gesture — the app debounces the + respawn, not the popover. + """ + pop = Gtk.Popover() + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, + margin_top=12, margin_bottom=12, + margin_start=12, margin_end=12) + pop.set_child(box) + + def row(label, widget): + r = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + lbl = Gtk.Label(label=label, xalign=0) + lbl.set_width_chars(9) + r.append(lbl) + r.append(widget) + box.append(r) + + self._fx_lowcut = Gtk.DropDown.new_from_strings( + ["Off", "80 Hz", "120 Hz"]) + self._fx_lowcut.connect("notify::selected", self._on_fx_changed) + row("Low cut", self._fx_lowcut) + + def eq_scale(): + s = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, + draw_value=True, digits=0, + adjustment=Gtk.Adjustment( + lower=-12, upper=12, step_increment=1, page_increment=3), + hexpand=True, + ) + s.set_size_request(160, -1) + s.add_mark(0, Gtk.PositionType.BOTTOM, None) + s.connect("value-changed", self._on_fx_changed) + return s + + self._fx_eq_low = eq_scale() + self._fx_eq_mid = eq_scale() + self._fx_eq_high = eq_scale() + row("Low dB", self._fx_eq_low) + row("Mid dB", self._fx_eq_mid) + row("High dB", self._fx_eq_high) + + self._fx_delay = Gtk.SpinButton( + adjustment=Gtk.Adjustment( + lower=0, upper=500, step_increment=5, page_increment=25), + climb_rate=1, digits=0, + ) + self._fx_delay.connect("value-changed", self._on_fx_changed) + row("Delay ms", self._fx_delay) + + self._fx_mono = Gtk.Switch(halign=Gtk.Align.START, + valign=Gtk.Align.CENTER) + self._fx_mono.connect("notify::active", self._on_fx_changed) + row("Mono", self._fx_mono) + + self._fx_updating = False + return pop + + def _on_fx_changed(self, *_args): + if getattr(self, "_fx_updating", False): + return + self.emit(self.__FX_SIGNAL) + + def fx_settings(self): + """The popover's current values, in the sources.DEFAULT_FX shape.""" + lowcut = (0, 80, 120)[self._fx_lowcut.get_selected()] + return { + "lowcut": lowcut, + "eq_low": float(self._fx_eq_low.get_value()), + "eq_mid": float(self._fx_eq_mid.get_value()), + "eq_high": float(self._fx_eq_high.get_value()), + "delay_ms": int(self._fx_delay.get_value()), + "mono": bool(self._fx_mono.get_active()), + } + + def set_fx(self, fx): + """Load stored settings into the popover without emitting.""" + if self._fx_widgets is None and not hasattr(self, "_fx_lowcut"): + return + self._fx_updating = True + try: + self._fx_lowcut.set_selected( + {0: 0, 80: 1, 120: 2}.get(int(fx.get("lowcut", 0)), 0)) + self._fx_eq_low.set_value(fx.get("eq_low", 0.0)) + self._fx_eq_mid.set_value(fx.get("eq_mid", 0.0)) + self._fx_eq_high.set_value(fx.get("eq_high", 0.0)) + self._fx_delay.set_value(fx.get("delay_ms", 0)) + self._fx_mono.set_active(bool(fx.get("mono", False))) + finally: + self._fx_updating = False + def set_removable(self, removable, tooltip="Remove source"): """Show or hide the remove button on a row that owns one. diff --git a/wavexlr/sources.py b/wavexlr/sources.py index a8abef4..fd809f6 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -217,6 +217,40 @@ def new_device_source(*, name, node_name, icon_name=DEFAULT_DEVICE_ICON): } +# Per-source DSP settings, stored on the source record because they are +# source identity like trim. Neutral values mean "this effect is off"; +# fx_active() is the single definition of whether a chain is needed at all. +DEFAULT_FX = { + "lowcut": 0, # Hz: 0 (off), 80 or 120 + "eq_low": 0.0, # dB, low shelf @ 100 Hz + "eq_mid": 0.0, # dB, peaking @ 1 kHz + "eq_high": 0.0, # dB, high shelf @ 8 kHz + "delay_ms": 0, # alignment delay + "mono": False, # force centered mono +} + + +def fx(source): + """A source's DSP settings, defaults filled in.""" + stored = (source or {}).get("fx") or {} + return {**DEFAULT_FX, **stored} + + +def fx_active(source): + """Whether any effect departs from neutral — the chain exists only then. + + Neutral settings spawn nothing: a pass-through filter node would cost a + process and a resample for silence-shaped benefit. + """ + f = fx(source) + return bool( + f["lowcut"] + or f["eq_low"] or f["eq_mid"] or f["eq_high"] + or f["delay_ms"] + or f["mono"] + ) + + def add(sources, source): sources[source["id"]] = source save(sources) From a7be7d2bfba714243889d3a3d964b148292cc729 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 19:38:28 -0500 Subject: [PATCH 85/99] Add a gate and a compressor to the microphone chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/support.py | 1 + tests/test_fx.py | 40 +++++++++++++++++++++++ wavexlr/mixer.py | 75 +++++++++++++++++++++++++++++++++++++++----- wavexlr/mixmatrix.py | 43 +++++++++++++++++++++++++ wavexlr/sources.py | 6 ++++ 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/tests/support.py b/tests/support.py index 055f3d8..5912390 100644 --- a/tests/support.py +++ b/tests/support.py @@ -65,6 +65,7 @@ def bare_mixer(**attrs): # so a test can call the real entry points and inspect the state they # persisted without a subprocess ever being spawned. mx._fx_conf = {} + mx._fx_failed = {} mx._pending = {} mx._pending_lock = threading.Lock() mx._wake = threading.Event() diff --git a/tests/test_fx.py b/tests/test_fx.py index af9f38b..bf2bf4a 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -33,6 +33,19 @@ def test_each_effect_contributes_its_node(self): # sequential chain: every adjacent pair is linked self.assertIn('output = "hp:Out" input = "eql:In"', conf) + def test_gate_and_compressor_are_ladspa_nodes_in_strip_order(self): + conf = mixer_mod.render_fx_config(_dev_source( + lowcut=80, gate=True, gate_thresh=-45.0, + comp=True, comp_thresh=-20.0, comp_ratio=4.0)) + self.assertIn("type = ladspa", conf) + self.assertIn('plugin = "gate_1410"', conf) + self.assertIn('"Threshold (dB)" = -45.0', conf) + self.assertIn('plugin = "sc4m_1916"', conf) + self.assertIn('"Ratio (1:n)" = 4.0', conf) + # channel-strip order: cut, gate, compress + self.assertIn('output = "hp:Out" input = "gate:In"', conf) + self.assertIn('output = "gate:Out" input = "comp:In"', conf) + def test_neutral_plus_mono_is_a_bare_copy(self): conf = mixer_mod.render_fx_config(_dev_source(mono=True)) self.assertIn("label = copy", conf) @@ -97,6 +110,33 @@ def test_cells_drink_from_the_chain_while_it_runs(self): "not the raw device") self.assertNotIn(("ports", "-o", ARCTIS), self.pw.calls) + def test_a_dying_chain_does_not_respawn_loop(self): + """A missing LADSPA library kills the chain instantly; respawning + every reconcile would fork a corpse every two seconds forever.""" + self.mx._sources["dock"]["fx"] = {"gate": True} + self.mx._reconcile_fx("dock") + self._fx_procs()[0].dies() + self.mx._reconcile_fx("dock") + self.assertEqual(len(self._fx_procs()), 1, "no respawn after death") + self.mx._reconcile_fx("dock") + self.assertEqual(len(self._fx_procs()), 1) + # a settings change is consent to try again + self.mx._sources["dock"]["fx"] = {"gate": True, "lowcut": 80} + self.mx._reconcile_fx("dock") + self.assertEqual(len(self._fx_procs()), 2) + + def test_a_reaped_corpse_still_reads_as_death(self): + """_reap_dead collects dead children before the fx pass looks, so + an absent proc under a known config is the failure, not a fresh + start — treating it as fresh was a slow respawn loop.""" + self.mx._sources["dock"]["fx"] = {"gate": True} + self.mx._reconcile_fx("dock") + self._fx_procs()[0].dies() + self.mx._procs.pop(self.mx._fx_key("dock")) # what _reap_dead does + self.mx._reconcile_fx("dock") + self.assertEqual(len(self._fx_procs()), 1, "no respawn after reap") + self.assertIn("dock", self.mx._fx_failed) + def test_replug_tears_the_chain_down_for_respawn(self): self.mx._reconcile_fx("dock") first = self._fx_procs()[0] diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 9f0576b..4f39533 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -103,6 +103,28 @@ def render_fx_config(source): if f["lowcut"]: nodes.append(("hp", "bq_highpass", f'control = {{ "Freq" = {float(f["lowcut"]):.1f} }}')) + # Gate before compressor, both before tone: standard channel-strip + # order — gate on the raw dynamics, compress what survives, then EQ. + # These two are LADSPA (swh-plugins); a missing library kills the + # chain's process, which _reconcile_fx turns into one logged warning + # and a fallback to the raw device rather than a respawn loop. + if f["gate"]: + nodes.append(( + "gate", "ladspa/gate", + 'plugin = "gate_1410" ' + f'control = {{ "Threshold (dB)" = {float(f["gate_thresh"]):.1f} ' + '"Attack (ms)" = 10.0 "Hold (ms)" = 120.0 "Decay (ms)" = 150.0 ' + '"Range (dB)" = -70.0 "LF key filter (Hz)" = 30.8 ' + '"HF key filter (Hz)" = 23000.0 "Output select" = 0.0 }')) + if f["comp"]: + nodes.append(( + "comp", "ladspa/sc4m", + 'plugin = "sc4m_1916" ' + f'control = {{ "Threshold level (dB)" = {float(f["comp_thresh"]):.1f} ' + f'"Ratio (1:n)" = {float(f["comp_ratio"]):.1f} ' + '"RMS/peak" = 0.0 "Attack time (ms)" = 15.0 ' + '"Release time (ms)" = 150.0 "Knee radius (dB)" = 3.0 ' + '"Makeup gain (dB)" = 0.0 }')) if f["eq_low"]: nodes.append(("eql", "bq_lowshelf", f'control = {{ "Freq" = 100.0 "Gain" = {float(f["eq_low"]):.1f} }}')) @@ -120,11 +142,14 @@ def render_fx_config(source): if not nodes: nodes.append(("thru", "copy", "")) - node_lines = "\n".join( - f' {{ type = builtin name = {name} ' - f'label = {label} {extra} }}' - for name, label, extra in nodes - ) + def _node_line(name, label, extra): + if label.startswith("ladspa/"): + return (f' {{ type = ladspa name = {name} ' + f'label = {label.split("/", 1)[1]} {extra} }}') + return (f' {{ type = builtin name = {name} ' + f'label = {label} {extra} }}') + + node_lines = "\n".join(_node_line(*n) for n in nodes) link_lines = "\n".join( f' {{ output = "{a[0]}:Out" input = "{b[0]}:In" }}' for a, b in zip(nodes, nodes[1:]) @@ -853,6 +878,7 @@ def __init__(self, pw=None): self._lock = Lock() self._procs = {} self._fx_conf = {} # source_id -> rendered fx config, for respawn diff + self._fx_failed = {} # source_id -> config a chain died under self._state = self._load_state() if self._migrate_state(): self._save_state() @@ -1347,6 +1373,23 @@ def poll_streams(self): self._streams = new if added or removed: self._enqueue(("poll",), self._reconcile_all) + else: + # A quiet graph still needs the fx chains health-checked: a + # chain that died (missing plugin, crash) would otherwise wait + # for an unrelated stream event before anyone noticed — the + # warning and the raw-device fallback both live in that pass. + dead = [ + sid for sid in list(self._fx_conf) + if sid not in self._fx_failed + and ((p := self._procs.get(self._fx_key(sid))) is None + or p.poll() is not None) + ] + if dead: + self._enqueue( + ("fx-health",), + lambda sids=tuple(dead): [ + self._reconcile_fx(s) for s in sids], + ) return added, removed # ------------------------------------------------------------ volumes def _volumes(self): @@ -1620,6 +1663,7 @@ def _drop_device_cell_loopbacks(self, nodes): # so the reconcile respawns it against the reincarnation. self._destroy_loopback(self._fx_key(sid)) self._fx_conf.pop(sid, None) + self._fx_failed.pop(sid, None) def request_capture_poll(self): """Re-snapshot capture devices on the worker, reconciling if it moved. @@ -1923,10 +1967,27 @@ def _reconcile_fx(self, source_id): self._fx_conf.pop(source_id, None) return conf = render_fx_config(source) + if self._fx_failed.get(source_id) == conf: + return # died under exactly these settings; wait for a change proc = self._procs.get(key) - if proc is not None and proc.poll() is None \ - and self._fx_conf.get(source_id) == conf: + if self._fx_conf.get(source_id) == conf: + if proc is not None and proc.poll() is None: + return + # The chain we spawned for exactly these settings is gone — a + # bad config or a missing plugin — and _reap_dead may already + # have collected the corpse, which is why "proc is None" with a + # known config is death too, not "never spawned". Respawning + # would loop it every reconcile; remember the settings, say why + # once, fall back to the raw device (cell targeting checks + # chain liveness). + self._fx_failed[source_id] = conf + hint = (" — a LADSPA plugin is missing; install swh-plugins" + if "type = ladspa" in conf else "") + _log.warning("fx chain for %s exited; running without effects%s", + source.get("name", source_id), hint) + self._destroy_loopback(key) return + self._fx_failed.pop(source_id, None) self._destroy_loopback(key) path = fx_config_path(source_id) os.makedirs(os.path.dirname(path), exist_ok=True) diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 984bd83..1a41b75 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -974,6 +974,39 @@ def row(label, widget): self._fx_lowcut.connect("notify::selected", self._on_fx_changed) row("Low cut", self._fx_lowcut) + def switch(): + s = Gtk.Switch(halign=Gtk.Align.START, valign=Gtk.Align.CENTER) + s.connect("notify::active", self._on_fx_changed) + return s + + def scale(lo, hi, step, digits=0): + s = Gtk.Scale( + orientation=Gtk.Orientation.HORIZONTAL, + draw_value=True, digits=digits, + adjustment=Gtk.Adjustment( + lower=lo, upper=hi, step_increment=step, + page_increment=step * 5), + hexpand=True, + ) + s.set_size_request(160, -1) + s.connect("value-changed", self._on_fx_changed) + return s + + # Gate and compressor need swh-plugins; when the library is + # missing the chain falls back to the raw device and the log says + # which package to install. + self._fx_gate = switch() + row("Gate", self._fx_gate) + self._fx_gate_thresh = scale(-70, -20, 1) + row("Gate dB", self._fx_gate_thresh) + + self._fx_comp = switch() + row("Comp", self._fx_comp) + self._fx_comp_thresh = scale(-40, 0, 1) + row("Comp dB", self._fx_comp_thresh) + self._fx_comp_ratio = scale(1, 10, 0.5, digits=1) + row("Ratio", self._fx_comp_ratio) + def eq_scale(): s = Gtk.Scale( orientation=Gtk.Orientation.HORIZONTAL, @@ -1020,6 +1053,11 @@ def fx_settings(self): lowcut = (0, 80, 120)[self._fx_lowcut.get_selected()] return { "lowcut": lowcut, + "gate": bool(self._fx_gate.get_active()), + "gate_thresh": float(self._fx_gate_thresh.get_value()), + "comp": bool(self._fx_comp.get_active()), + "comp_thresh": float(self._fx_comp_thresh.get_value()), + "comp_ratio": float(self._fx_comp_ratio.get_value()), "eq_low": float(self._fx_eq_low.get_value()), "eq_mid": float(self._fx_eq_mid.get_value()), "eq_high": float(self._fx_eq_high.get_value()), @@ -1035,6 +1073,11 @@ def set_fx(self, fx): try: self._fx_lowcut.set_selected( {0: 0, 80: 1, 120: 2}.get(int(fx.get("lowcut", 0)), 0)) + self._fx_gate.set_active(bool(fx.get("gate", False))) + self._fx_gate_thresh.set_value(fx.get("gate_thresh", -50.0)) + self._fx_comp.set_active(bool(fx.get("comp", False))) + self._fx_comp_thresh.set_value(fx.get("comp_thresh", -18.0)) + self._fx_comp_ratio.set_value(fx.get("comp_ratio", 3.0)) self._fx_eq_low.set_value(fx.get("eq_low", 0.0)) self._fx_eq_mid.set_value(fx.get("eq_mid", 0.0)) self._fx_eq_high.set_value(fx.get("eq_high", 0.0)) diff --git a/wavexlr/sources.py b/wavexlr/sources.py index fd809f6..49aaf75 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -222,6 +222,11 @@ def new_device_source(*, name, node_name, icon_name=DEFAULT_DEVICE_ICON): # fx_active() is the single definition of whether a chain is needed at all. DEFAULT_FX = { "lowcut": 0, # Hz: 0 (off), 80 or 120 + "gate": False, # noise gate (LADSPA swh gate) + "gate_thresh": -50.0, # dB the gate opens at + "comp": False, # compressor (LADSPA swh sc4m) + "comp_thresh": -18.0, # dB compression starts at + "comp_ratio": 3.0, # 1:n above threshold "eq_low": 0.0, # dB, low shelf @ 100 Hz "eq_mid": 0.0, # dB, peaking @ 1 kHz "eq_high": 0.0, # dB, high shelf @ 8 kHz @@ -245,6 +250,7 @@ def fx_active(source): f = fx(source) return bool( f["lowcut"] + or f["gate"] or f["comp"] or f["eq_low"] or f["eq_mid"] or f["eq_high"] or f["delay_ms"] or f["mono"] From 32c939a21f8f91f82a33a7c61077e065cd2a17d3 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 19:44:11 -0500 Subject: [PATCH 86/99] Speak the LADSPA plugins' real port names, verified against the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_fx.py | 4 ++-- wavexlr/mixer.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_fx.py b/tests/test_fx.py index bf2bf4a..cd0e191 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -43,8 +43,8 @@ def test_gate_and_compressor_are_ladspa_nodes_in_strip_order(self): self.assertIn('plugin = "sc4m_1916"', conf) self.assertIn('"Ratio (1:n)" = 4.0', conf) # channel-strip order: cut, gate, compress - self.assertIn('output = "hp:Out" input = "gate:In"', conf) - self.assertIn('output = "gate:Out" input = "comp:In"', conf) + self.assertIn('output = "hp:Out" input = "gate:Input"', conf) + self.assertIn('output = "gate:Output" input = "comp:Input"', conf) def test_neutral_plus_mono_is_a_bare_copy(self): conf = mixer_mod.render_fx_config(_dev_source(mono=True)) diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 4f39533..ca01969 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -115,7 +115,10 @@ def render_fx_config(source): f'control = {{ "Threshold (dB)" = {float(f["gate_thresh"]):.1f} ' '"Attack (ms)" = 10.0 "Hold (ms)" = 120.0 "Decay (ms)" = 150.0 ' '"Range (dB)" = -70.0 "LF key filter (Hz)" = 30.8 ' - '"HF key filter (Hz)" = 23000.0 "Output select" = 0.0 }')) + '"HF key filter (Hz)" = 23000.0 ' + # The port's NAME includes its legend, verified against the + # installed library — "Output select" alone is not a port. + '"Output select (-1 = key listen, 0 = gate, 1 = bypass)" = 0.0 }')) if f["comp"]: nodes.append(( "comp", "ladspa/sc4m", @@ -150,8 +153,17 @@ def _node_line(name, label, extra): f'label = {label} {extra} }}') node_lines = "\n".join(_node_line(*n) for n in nodes) + # Builtins name their audio ports In/Out; LADSPA nodes expose the + # library's own names, which for the swh plugins are Input/Output — + # verified against the installed .so, and a wrong name is fatal to + # the whole graph, not a warning. + def _ports(node): + return (("Input", "Output") if node[1].startswith("ladspa/") + else ("In", "Out")) + link_lines = "\n".join( - f' {{ output = "{a[0]}:Out" input = "{b[0]}:In" }}' + f' {{ output = "{a[0]}:{_ports(a)[1]}" ' + f'input = "{b[0]}:{_ports(b)[0]}" }}' for a, b in zip(nodes, nodes[1:]) ) label = source.get("name", source["id"]) From 3ed40667eec6f770c019db80127c2bbc2e7a4d6e Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 19:50:28 -0500 Subject: [PATCH 87/99] Rebuild a cell's loopback when its capture source changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/support.py | 1 + tests/test_fx.py | 26 ++++++++++++++++++++++++++ wavexlr/mixer.py | 12 ++++++++++++ wavexlr/mixmatrix.py | 20 ++++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/tests/support.py b/tests/support.py index 5912390..cc1bdfc 100644 --- a/tests/support.py +++ b/tests/support.py @@ -66,6 +66,7 @@ def bare_mixer(**attrs): # persisted without a subprocess ever being spawned. mx._fx_conf = {} mx._fx_failed = {} + mx._cell_capture = {} mx._pending = {} mx._pending_lock = threading.Lock() mx._wake = threading.Event() diff --git a/tests/test_fx.py b/tests/test_fx.py index cd0e191..71b960d 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -110,6 +110,32 @@ def test_cells_drink_from_the_chain_while_it_runs(self): "not the raw device") self.assertNotIn(("ports", "-o", ARCTIS), self.pw.calls) + def test_existing_cells_retarget_when_fx_toggles_on(self): + """The real-world order: cells exist first, fx enabled later. The + loopback's links are made once at spawn, so retargeting means + rebuild — an existing process is an existing ROUTE.""" + self.mx._mixes = {"chat": {"id": "chat", "name": "Chat", + "sink": "openwave_chat_mix"}} + self.mx._state = {"dock.chat": {"volume": 0.8, "muted": False}} + self.mx._sources["dock"]["fx"] = {} + self.mx._reconcile_cell("dock", "chat") # raw route exists + raw_loop = [p for p in self.pw.spawned if p.argv[0] != "pipewire"][0] + + self.mx._sources["dock"]["fx"] = {"lowcut": 80} + self.mx._reconcile_fx("dock") + self.mx._reconcile_cell("dock", "chat") + self.assertTrue(raw_loop.terminated, + "the raw-route loopback must be rebuilt") + self.assertIn(("ports", "-o", mixer_mod.fx_node_name("dock")), + self.pw.calls) + + # and back off again + self.pw.calls.clear() + self.mx._sources["dock"]["fx"] = {} + self.mx._reconcile_fx("dock") + self.mx._reconcile_cell("dock", "chat") + self.assertIn(("ports", "-o", ARCTIS), self.pw.calls) + def test_a_dying_chain_does_not_respawn_loop(self): """A missing LADSPA library kills the chain instantly; respawning every reconcile would fork a corpse every two seconds forever.""" diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index ca01969..12609fb 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -891,6 +891,7 @@ def __init__(self, pw=None): self._procs = {} self._fx_conf = {} # source_id -> rendered fx config, for respawn diff self._fx_failed = {} # source_id -> config a chain died under + self._cell_capture = {} # cell key -> node its loopback drinks from self._state = self._load_state() if self._migrate_state(): self._save_state() @@ -1228,6 +1229,7 @@ def _link_capture(self, source_node_name, capture_node_name, retries=20): return def _destroy_loopback(self, key): + self._cell_capture.pop(key, None) proc = self._procs.pop(key, None) if proc is None: return @@ -2078,6 +2080,14 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted if not capture_node or not mix_sink or volume <= 0.0 or absent: self._destroy_loopback(key) return + # A live loopback pinned to a different capture source than the one + # wanted now — the fx chain toggled on or off — must be rebuilt: + # the links are made once at spawn, so an existing process is an + # existing ROUTE, not just an existing process. Without this, cells + # created before the chain kept drinking raw forever. + if key in self._procs \ + and self._cell_capture.get(key) not in (None, capture_node): + self._destroy_loopback(key) if key not in self._procs: with self._lock: src_name = (self._sources.get(source_id) or {}).get( @@ -2087,6 +2097,8 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted key, capture_node, mix_sink, node_name, description=f"{src_name} \u2192 {mix_name}", ) + if key in self._procs: + self._cell_capture[key] = capture_node node_id = self._pw.node_id(node_name) if node_id is not None: # cell fader x source trim: the row slider scales this source diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 1a41b75..37e92b6 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -1007,6 +1007,26 @@ def scale(lo, hi, step, digits=0): self._fx_comp_ratio = scale(1, 10, 0.5, digits=1) row("Ratio", self._fx_comp_ratio) + # A slider whose effect is off is either misleading (it moves, + # nothing happens) or a statement of intent. Both, resolved: + # the sliders dim while their switch is off, and dragging one + # anyway flips the switch — choosing a threshold IS enabling. + def bind(sw, *scales): + def sync(*_a): + for s in scales: + s.set_sensitive(sw.get_active()) + sw.connect("notify::active", sync) + sync() + for s in scales: + def enable(_s, sw=sw): + if not getattr(self, "_fx_updating", False) \ + and not sw.get_active(): + sw.set_active(True) + s.connect("value-changed", enable) + + bind(self._fx_gate, self._fx_gate_thresh) + bind(self._fx_comp, self._fx_comp_thresh, self._fx_comp_ratio) + def eq_scale(): s = Gtk.Scale( orientation=Gtk.Orientation.HORIZONTAL, From 7c7b4268b1d2206859483158510ed9066d90df7c Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 19:57:22 -0500 Subject: [PATCH 88/99] Let the session manager own the links a real Source can carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_fx.py | 16 +++++++++------- wavexlr/mixer.py | 22 +++++++++++++++++++--- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/test_fx.py b/tests/test_fx.py index 71b960d..1fe148d 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -105,10 +105,11 @@ def test_cells_drink_from_the_chain_while_it_runs(self): self.mx._reconcile_fx("dock") self.mx._reconcile_cell("dock", "chat") fx_node = mixer_mod.fx_node_name("dock") - self.assertIn(("ports", "-o", fx_node), self.pw.calls, - "the cell loopback must link from the fx node, " + loops = [p for p in self.pw.spawned if p.argv[0] == "pw-loopback"] + cap = loops[-1].argv[1] + self.assertIn(f"target.object={fx_node}", cap, + "the cell loopback must capture the fx node, " "not the raw device") - self.assertNotIn(("ports", "-o", ARCTIS), self.pw.calls) def test_existing_cells_retarget_when_fx_toggles_on(self): """The real-world order: cells exist first, fx enabled later. The @@ -126,15 +127,16 @@ def test_existing_cells_retarget_when_fx_toggles_on(self): self.mx._reconcile_cell("dock", "chat") self.assertTrue(raw_loop.terminated, "the raw-route loopback must be rebuilt") - self.assertIn(("ports", "-o", mixer_mod.fx_node_name("dock")), - self.pw.calls) + loops = [p for p in self.pw.spawned if p.argv[0] == "pw-loopback"] + self.assertIn(f"target.object={mixer_mod.fx_node_name('dock')}", + loops[-1].argv[1]) # and back off again - self.pw.calls.clear() self.mx._sources["dock"]["fx"] = {} self.mx._reconcile_fx("dock") self.mx._reconcile_cell("dock", "chat") - self.assertIn(("ports", "-o", ARCTIS), self.pw.calls) + loops = [p for p in self.pw.spawned if p.argv[0] == "pw-loopback"] + self.assertIn(f"target.object={ARCTIS}", loops[-1].argv[1]) def test_a_dying_chain_does_not_respawn_loop(self): """A missing LADSPA library kills the chain instantly; respawning diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 12609fb..48190ef 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1164,7 +1164,7 @@ def streams(self): # ----- subprocess lifecycle ----- def _spawn_loopback(self, key, capture_source_name, playback_target, node_name, detach=False, playback_extra="", - description=None): + description=None, native_capture=False): """Spawn a pw-loopback and *manually* link the capture side to `capture_source_name`'s output ports. We disable autoconnect on capture because the session manager will otherwise hijack the loopback by @@ -1191,11 +1191,23 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, ident = f'application.name=OpenWave node.description="{label}" ' cap_ident = f'application.name=OpenWave node.description="{label} (capture)" ' + # Manual linking exists because a null sink's MONITOR cannot be an + # autoconnect target — the session manager falls back to the + # default source, which is the hijack documented above. A real + # Audio/Source (a hardware capture node, an fx chain's published + # Source) resolves fine, and letting WirePlumber own that link + # means it also REPAIRS it — hand-made links die silently with + # their node and stay dead. native_capture chooses per target. + if native_capture: + cap_props = (f"target.object={capture_source_name} " + f"node.name={capture_node_name} ") + else: + cap_props = f"node.autoconnect=false node.name={capture_node_name} " proc = self._pw.spawn_loopback( [ "pw-loopback", "--capture-props=" - f"node.autoconnect=false node.name={capture_node_name} " + + cap_props + cap_ident + "audio.channels=2 audio.position=[FL,FR]", "--playback-props=" @@ -1210,7 +1222,8 @@ def _spawn_loopback(self, key, capture_source_name, playback_target, if proc is None: return self._procs[key] = proc - self._link_capture(capture_source_name, capture_node_name) + if not native_capture: + self._link_capture(capture_source_name, capture_node_name) def _link_capture(self, source_node_name, capture_node_name, retries=20): """Wire each output port of `source_node_name` to a corresponding @@ -2096,6 +2109,9 @@ def _reconcile_capture_cell(self, source_id, mix_id, capture_node, volume, muted self._spawn_loopback( key, capture_node, mix_sink, node_name, description=f"{src_name} \u2192 {mix_name}", + # Devices and fx chains are real Sources: the session + # manager can own \u2014 and repair \u2014 this link. + native_capture=True, ) if key in self._procs: self._cell_capture[key] = capture_node From 2b76dd05f82c63815408926ead71a7e718a429cd Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 20:03:28 -0500 Subject: [PATCH 89/99] Calibrate the gate and compressor by listening, not guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- CHANGELOG.md | 6 +++ tests/test_calibrate.py | 47 ++++++++++++++++++ wavexlr/app.py | 93 ++++++++++++++++++++++++++++++++++++ wavexlr/calibrate.py | 102 ++++++++++++++++++++++++++++++++++++++++ wavexlr/mixmatrix.py | 8 ++++ 5 files changed, 256 insertions(+) create mode 100644 tests/test_calibrate.py create mode 100644 wavexlr/calibrate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e9bce..7b24954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ versions are git tags (see [Releases](../../releases)). connected, the capture-fix daemon keeps one keepalive pin per device, scenes record hardware state per serial number, and the tray reports muted when any device's hardware mute is down. +- **Auto-calibration**: one button in the effects popover measures the + microphone — three silent seconds for the floor, five spoken ones for + the voice — and sets the gate between floor and quietest word, the + compressor under the loudest, reporting the numbers it heard. A + measurement that hears no clear speech says so instead of emitting a + threshold computed from silence. - **Per-microphone DSP chain**: every capture row gains an effects popover — low cut (80/120 Hz), three-band presence EQ, alignment delay up to 500 ms, and forced mono — built from PipeWire's builtin diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py new file mode 100644 index 0000000..3e4c863 --- /dev/null +++ b/tests/test_calibrate.py @@ -0,0 +1,47 @@ +"""Calibration analysis: measurements in, sane thresholds out.""" + +import unittest + +from wavexlr import calibrate + + +def windows(db, count=100): + return [float(db)] * count + + +class Analyze(unittest.TestCase): + def test_typical_setup(self): + """Floor -62, voice -30ish: gate lands between them, comp above.""" + speech = windows(-30, 80) + windows(-60, 20) # pauses included + r = calibrate.analyze(windows(-62), speech) + f = r["fx"] + self.assertTrue(f["gate"] and f["comp"]) + self.assertGreater(f["gate_thresh"], -62 + 7) + self.assertLess(f["gate_thresh"], -30, "gate must sit below voice") + self.assertAlmostEqual(f["comp_thresh"], -36, delta=3) + + def test_quiet_voice_still_wins_over_margin(self): + """A voice barely above the floor: the gate hugs the floor rather + than eating words.""" + r = calibrate.analyze(windows(-60), windows(-45, 90) + windows(-60, 10)) + self.assertLessEqual(r["fx"]["gate_thresh"], -52, + "quiet-voice margin must dominate") + + def test_loud_floor_clamps_into_range(self): + r = calibrate.analyze(windows(-25), windows(-8)) + self.assertGreaterEqual(r["fx"]["gate_thresh"], -70) + self.assertLessEqual(r["fx"]["gate_thresh"], -20) + self.assertLessEqual(r["fx"]["comp_thresh"], 0) + + def test_no_speech_is_an_explanation_not_a_threshold(self): + with self.assertRaisesRegex(calibrate.CalibrationError, "speech"): + calibrate.analyze(windows(-62), windows(-60)) + + def test_measured_levels_are_reported(self): + r = calibrate.analyze(windows(-62), windows(-28)) + self.assertEqual(r["measured"]["floor_db"], -62.0) + self.assertEqual(r["measured"]["loud_voice_db"], -28.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index 8d77fc8..d6e2a74 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1666,6 +1666,7 @@ def _wire_source_row(self, source_id): if sources_module.kind(source) == sources_module.KIND_DEVICE: cell.set_fx(sources_module.fx(source)) cell.connect("fx-changed", self._on_source_fx_changed, source_id) + cell.connect("fx-autotune", self._on_fx_autotune, source_id) source = self._sources.get(source_id, {}) cell.set_volume(float(source.get("level", 1.0))) cell.set_muted(bool(source.get("muted", False))) @@ -1678,6 +1679,98 @@ def _on_source_level_changed(self, _cell, volume, source_id): source_id, volume, self._sources.get(source_id, {}).get("muted", False)) sources_module.save(self._sources) + def _on_fx_autotune(self, cell, source_id): + """Two guided measurements, then gate and compressor set to fit. + + Measured on the RAW device node — the chain, if any, keeps + running untouched, so a re-calibration is not itself colored by + the previous calibration. + """ + from . import calibrate + source = self._sources.get(source_id) + node = (source or {}).get("node_name") + if not node or not self.mixer.capture_device_present(node): + err = Adw.AlertDialog(heading="Cannot calibrate", + body="The device is not connected.") + err.add_response("ok", "OK") + err.choose(self, None, lambda d, r: d.choose_finish(r)) + return + + intro = Adw.AlertDialog( + heading="Auto-calibrate", + body=("Two short measurements of this microphone:\n\n" + f"1. Stay silent for {calibrate.FLOOR_SECONDS} seconds " + "(room + device noise floor)\n" + f"2. Speak normally for {calibrate.SPEECH_SECONDS} seconds\n\n" + "Gate and compressor are then set to fit what was heard."), + ) + intro.add_response("cancel", "Cancel") + intro.add_response("start", "Start") + intro.set_response_appearance("start", Adw.ResponseAppearance.SUGGESTED) + intro.set_default_response("start") + + def _go(d, result): + if d.choose_finish(result) == "start": + self._calibrate_run(cell, source_id, node) + + intro.choose(self, None, _go) + + def _calibrate_run(self, cell, source_id, node): + from . import calibrate + state = {"cancelled": False} + prog = Adw.AlertDialog(heading="Calibrating…", + body="🤫 Stay silent…") + prog.add_response("cancel", "Cancel") + + def _on_cancel(d, result): + d.choose_finish(result) + state["cancelled"] = True + + prog.choose(self, None, _on_cancel) + + def _work(): + floor = calibrate.capture_window_peaks_db( + node, calibrate.FLOOR_SECONDS) + if state["cancelled"]: + return None + GLib.idle_add(prog.set_body, "🗣 Now speak normally…") + speech = calibrate.capture_window_peaks_db( + node, calibrate.SPEECH_SECONDS) + if state["cancelled"]: + return None + return calibrate.analyze(floor, speech) + + def _done(result): + prog.force_close() + if result is None: + return + source = self._sources.get(source_id) + if source is None: + return + source["fx"] = {**sources_module.fx(source), **result["fx"]} + cell.set_fx(sources_module.fx(source)) + sources_module.save(self._sources) + self.mixer.set_sources(self._sources) + m, f = result["measured"], result["fx"] + report = Adw.AlertDialog( + heading="Calibrated", + body=(f"Noise floor: {m['floor_db']} dBFS\n" + f"Voice: {m['quiet_voice_db']} to " + f"{m['loud_voice_db']} dBFS\n\n" + f"Gate set to {f['gate_thresh']} dB, compressor to " + f"{f['comp_thresh']} dB at {f['comp_ratio']:.0f}:1."), + ) + report.add_response("ok", "OK") + report.choose(self, None, lambda d, r: d.choose_finish(r)) + + def _fail(exc): + prog.force_close() + err = Adw.AlertDialog(heading="Calibration failed", body=str(exc)) + err.add_response("ok", "OK") + err.choose(self, None, lambda d, r: d.choose_finish(r)) + + self._usb_async(_work, on_done=_done, on_error=_fail) + _FX_DEBOUNCE_MS = 400 def _on_source_fx_changed(self, cell, source_id): diff --git a/wavexlr/calibrate.py b/wavexlr/calibrate.py new file mode 100644 index 0000000..9582718 --- /dev/null +++ b/wavexlr/calibrate.py @@ -0,0 +1,102 @@ +"""Auto-calibration: measure a microphone, propose gate and compressor. + +Two captures off the RAW device node — a silent stretch for the noise +floor, a spoken stretch for the voice — reduced to per-window peak +levels, then turned into settings by rules a broadcast engineer would +recognise: the gate threshold sits safely above the floor but below the +quietest voiced material, the compressor threshold rides a bit under the +loudest. Analysis is pure and unit-tested; only the capture touches the +graph. +""" + +import math +import struct +import subprocess + +RATE = 48000 +WINDOW = 1600 # ~33 ms of s16 mono @ 48 kHz +FLOOR_SECONDS = 3 +SPEECH_SECONDS = 5 + + +class CalibrationError(Exception): + pass + + +def capture_window_peaks_db(node_name, seconds): + """Per-window peak dBFS from `seconds` of one node, transient skipped.""" + # pw-cat records until killed; the duration is ours to enforce by + # reading exactly the byte budget and then stopping the child. + budget = RATE * 2 * seconds + RATE # + half a second of transient + try: + proc = subprocess.Popen( + ["pw-cat", "--record", "--target", node_name, + "--rate", str(RATE), "--channels", "1", "--format", "s16", "-"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ) + except OSError as exc: + raise CalibrationError(f"could not record {node_name}: {exc}") + chunks, got = [], 0 + try: + while got < budget: + chunk = proc.stdout.read(min(65536, budget - got)) + if not chunk: + break + chunks.append(chunk) + got += len(chunk) + finally: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + raw = b"".join(chunks)[RATE:] # drop the connection transient + peaks = [] + for i in range(0, len(raw) - WINDOW, WINDOW): + n = WINDOW // 2 + samples = struct.unpack(f"<{n}h", raw[i:i + WINDOW]) + peak = max(abs(s) for s in samples) / 32768.0 + peaks.append(20 * math.log10(max(peak, 1e-7))) + if len(peaks) < seconds * 10: + raise CalibrationError( + f"{node_name} delivered almost no audio — is the device stalled?") + return peaks + + +def _percentile(values, pct): + ordered = sorted(values) + return ordered[min(len(ordered) - 1, int(len(ordered) * pct / 100))] + + +def analyze(floor_peaks_db, speech_peaks_db): + """Turn the two measurements into fx settings, or raise with a reason. + + Voiced windows are those clearly above the floor; without enough of + them the speech phase heard nothing worth calibrating to, and saying + so beats emitting a gate threshold computed from silence. + """ + floor = _percentile(floor_peaks_db, 50) + voiced = [p for p in speech_peaks_db if p > floor + 10] + if len(voiced) < len(speech_peaks_db) * 0.1: + raise CalibrationError( + "I did not hear speech clearly above the noise floor — " + "try again closer to the microphone.") + quiet_voice = _percentile(voiced, 10) + loud_voice = _percentile(voiced, 90) + + # Gate: above the floor with margin, below the quietest voiced + # material with more margin — words must always win the argument. + gate_thresh = max(floor + 8.0, min(quiet_voice - 12.0, -20.0)) + gate_thresh = max(-70.0, min(-20.0, gate_thresh)) + + # Compressor: catch the loud peaks, leave normal speech alone. + comp_thresh = max(-40.0, min(0.0, loud_voice - 6.0)) + + return { + "measured": {"floor_db": round(floor, 1), + "quiet_voice_db": round(quiet_voice, 1), + "loud_voice_db": round(loud_voice, 1)}, + "fx": {"gate": True, "gate_thresh": round(gate_thresh, 1), + "comp": True, "comp_thresh": round(comp_thresh, 1), + "comp_ratio": 3.0}, + } diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 37e92b6..b507330 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -775,6 +775,8 @@ class SourceCell(Gtk.Box): "edit-clicked": (GObject.SignalFlags.RUN_FIRST, None, ()), # DSP popover moved; read the values back with fx_settings() "fx-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), + # "Auto" pressed in the DSP popover: run the calibration wizard + "fx-autotune": (GObject.SignalFlags.RUN_FIRST, None, ()), } def __init__(self, *, name, icon_name, has_level, removable=False, @@ -969,6 +971,12 @@ def row(label, widget): r.append(widget) box.append(r) + auto_btn = Gtk.Button(label="Auto-calibrate gate + comp") + auto_btn.connect("clicked", + lambda _b: (pop.popdown(), + self.emit("fx-autotune"))) + box.append(auto_btn) + self._fx_lowcut = Gtk.DropDown.new_from_strings( ["Off", "80 Hz", "120 Hz"]) self._fx_lowcut.connect("notify::selected", self._on_fx_changed) From e81d3ad689811ed49b1b85e40b4749465c3801a2 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 20:06:42 -0500 Subject: [PATCH 90/99] Calibrate the tone controls too: low cut, high shelf, mono MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- tests/test_calibrate.py | 69 +++++++++++++++++++++++ wavexlr/app.py | 21 +++++-- wavexlr/calibrate.py | 119 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index 3e4c863..9e8e8f8 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -43,5 +43,74 @@ def test_measured_levels_are_reported(self): self.assertEqual(r["measured"]["loud_voice_db"], -28.0) +def _tone_metrics(sub_db=-20, voice_low_db=-20, tilt_db=-15, balance=1.0): + return {"sub_db": sub_db, "voice_low_db": voice_low_db, + "tilt_db": tilt_db, "balance": balance, "peaks_db": []} + + +class AnalyzeTone(unittest.TestCase): + def test_rumbly_floor_gets_the_higher_cut(self): + fx = calibrate.analyze_tone(_tone_metrics(sub_db=-3), + _tone_metrics()) + self.assertEqual(fx["lowcut"], 120) + + def test_a_deep_voice_vetoes_the_high_cut(self): + """Fundamentals in the 90-180 octave: cutting at 120 thins the + voice, however rumbly the room.""" + fx = calibrate.analyze_tone(_tone_metrics(sub_db=-3), + _tone_metrics(voice_low_db=-6)) + self.assertEqual(fx["lowcut"], 80) + + def test_clean_floor_gets_the_gentle_default(self): + fx = calibrate.analyze_tone(_tone_metrics(sub_db=-25), + _tone_metrics()) + self.assertEqual(fx["lowcut"], 80) + + def test_dull_speech_earns_a_bounded_shelf_boost(self): + fx = calibrate.analyze_tone(_tone_metrics(), + _tone_metrics(tilt_db=-30)) + self.assertEqual(fx["eq_high"], 4.0, "clamped, never wild") + + def test_bright_speech_gets_a_trim(self): + fx = calibrate.analyze_tone(_tone_metrics(), + _tone_metrics(tilt_db=-7)) + self.assertLess(fx["eq_high"], 0) + + def test_normal_tilt_leaves_the_shelf_alone(self): + fx = calibrate.analyze_tone(_tone_metrics(), + _tone_metrics(tilt_db=-15)) + self.assertEqual(fx["eq_high"], 0.0) + + def test_one_sided_capture_suggests_mono(self): + fx = calibrate.analyze_tone(_tone_metrics(), + _tone_metrics(balance=0.01)) + self.assertTrue(fx.get("mono")) + fx = calibrate.analyze_tone(_tone_metrics(), + _tone_metrics(balance=0.8)) + self.assertNotIn("mono", fx) + + +class Metrics(unittest.TestCase): + def test_sine_energy_lands_in_its_band(self): + """A 60 Hz tone reads sub-heavy; a 6 kHz tone reads top-heavy.""" + import math as m + def stereo(freq, secs=1): + out = bytearray() + for i in range(48000 * secs): + v = int(20000 * m.sin(2 * m.pi * freq * i / 48000)) + out += v.to_bytes(2, "little", signed=True) * 2 + return bytes(out) + low = calibrate.metrics_from_raw(stereo(60)) + high = calibrate.metrics_from_raw(stereo(6000)) + self.assertGreater(low["sub_db"], -3) + self.assertLess(high["sub_db"], -20) + self.assertGreater(high["tilt_db"], low["tilt_db"]) + + def test_one_sided_stereo_reads_unbalanced(self): + frames = (b"\x10\x27" + b"\x00\x00") * 48000 # L loud, R silent + m = calibrate.metrics_from_raw(frames) + self.assertLess(m["balance"], 0.05) + + if __name__ == "__main__": unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index d6e2a74..f1605ec 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -1729,16 +1729,19 @@ def _on_cancel(d, result): prog.choose(self, None, _on_cancel) def _work(): - floor = calibrate.capture_window_peaks_db( - node, calibrate.FLOOR_SECONDS) + floor_m = calibrate.metrics_from_raw( + calibrate.capture_raw(node, calibrate.FLOOR_SECONDS)) if state["cancelled"]: return None GLib.idle_add(prog.set_body, "🗣 Now speak normally…") - speech = calibrate.capture_window_peaks_db( - node, calibrate.SPEECH_SECONDS) + speech_m = calibrate.metrics_from_raw( + calibrate.capture_raw(node, calibrate.SPEECH_SECONDS)) if state["cancelled"]: return None - return calibrate.analyze(floor, speech) + result = calibrate.analyze( + floor_m["peaks_db"], speech_m["peaks_db"]) + result["fx"].update(calibrate.analyze_tone(floor_m, speech_m)) + return result def _done(result): prog.force_close() @@ -1752,13 +1755,19 @@ def _done(result): sources_module.save(self._sources) self.mixer.set_sources(self._sources) m, f = result["measured"], result["fx"] + tone = f"Low cut {f.get('lowcut', 0)} Hz" + if f.get("eq_high"): + tone += f", high shelf {f['eq_high']:+.0f} dB" + if f.get("mono"): + tone += ", forced mono (one-sided capture)" report = Adw.AlertDialog( heading="Calibrated", body=(f"Noise floor: {m['floor_db']} dBFS\n" f"Voice: {m['quiet_voice_db']} to " f"{m['loud_voice_db']} dBFS\n\n" f"Gate set to {f['gate_thresh']} dB, compressor to " - f"{f['comp_thresh']} dB at {f['comp_ratio']:.0f}:1."), + f"{f['comp_thresh']} dB at {f['comp_ratio']:.0f}:1.\n" + f"{tone}."), ) report.add_response("ok", "OK") report.choose(self, None, lambda d, r: d.choose_finish(r)) diff --git a/wavexlr/calibrate.py b/wavexlr/calibrate.py index 9582718..5b50a42 100644 --- a/wavexlr/calibrate.py +++ b/wavexlr/calibrate.py @@ -63,6 +63,125 @@ def capture_window_peaks_db(node_name, seconds): return peaks +def capture_raw(node_name, seconds, channels=2): + """Exactly `seconds` of raw s16 off one node, transient dropped. + + Stereo by default: channel balance is one of the things calibration + can judge, and a mono device simply delivers two equal channels. + """ + frame = 2 * channels + budget = RATE * frame * seconds + RATE * frame // 2 + try: + proc = subprocess.Popen( + ["pw-cat", "--record", "--target", node_name, + "--rate", str(RATE), "--channels", str(channels), + "--format", "s16", "-"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ) + except OSError as exc: + raise CalibrationError(f"could not record {node_name}: {exc}") + chunks, got = [], 0 + try: + while got < budget: + chunk = proc.stdout.read(min(65536, budget - got)) + if not chunk: + break + chunks.append(chunk) + got += len(chunk) + finally: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + raw = b"".join(chunks)[RATE * frame // 2:] + if len(raw) < RATE * frame * seconds // 2: + raise CalibrationError( + f"{node_name} delivered almost no audio — is the device stalled?") + return raw + + +def _one_pole_energy(samples, cutoff): + """Mean energy of `samples` low-passed at `cutoff` Hz. Pure python — + numpy is not a dependency this project has, and a first-order filter + is plenty for octave-coarse decisions.""" + a = math.exp(-2.0 * math.pi * cutoff / RATE) + b = 1.0 - a + y = 0.0 + acc = 0.0 + for s in samples: + y = b * s + a * y + acc += y * y + return acc / max(len(samples), 1) + + +def metrics_from_raw(raw, channels=2): + """Everything the rules need, from one capture. + + Level metrics ride the mono mixdown; tone metrics are octave-coarse + energies from first-order filters; balance compares the channels. + """ + n = len(raw) // 2 + ints = struct.unpack(f"<{n}h", raw[:n * 2]) + if channels == 2: + left = ints[0::2] + right = ints[1::2] + mono = [(l + r) / 2.0 for l, r in zip(left, right)] + e_l = sum(v * v for v in left) / max(len(left), 1) + e_r = sum(v * v for v in right) / max(len(right), 1) + balance = (min(e_l, e_r) / max(e_l, e_r)) if max(e_l, e_r) else 1.0 + else: + mono = [float(v) for v in ints] + balance = 1.0 + + peaks = [] + half = WINDOW // 2 + for i in range(0, len(mono) - half, half): + peak = max(abs(s) for s in mono[i:i + half]) / 32768.0 + peaks.append(20 * math.log10(max(peak, 1e-7))) + + total = sum(v * v for v in mono) / max(len(mono), 1) + e90 = _one_pole_energy(mono, 90) # rumble + deepest fundamentals + e180 = _one_pole_energy(mono, 180) # ...plus the voice's low octave + e2k = _one_pole_energy(mono, 2000) + + def db(x, ref): + return 10 * math.log10(max(x, 1e-9) / max(ref, 1e-9)) + + return { + "peaks_db": peaks, + "balance": balance, + "sub_db": db(e90, total), # how much of it lives below ~90 Hz + "voice_low_db": db(e180 - e90, total), # the 90–180 Hz octave + "tilt_db": db(total - e2k, total), # energy above ~2 kHz vs everything + } + + +def analyze_tone(floor_metrics, speech_metrics): + """Low cut, high shelf and mono from the tone metrics. + + Every rule bounded and explainable: the low cut never sits on top of + a deep voice's fundamentals, the shelf only nudges toward a normal + speech tilt, and mono is suggested only for a lopsided capture. + """ + fx = {} + # Deep voice: real energy in the 90–180 octave vetoes the 120 Hz cut. + deep_voice = speech_metrics["voice_low_db"] > -12.0 + rumbly_floor = floor_metrics["sub_db"] > -6.0 + fx["lowcut"] = 80 if deep_voice else (120 if rumbly_floor else 80) + + # Typical close-mic speech carries its top ~10–20 dB under the body; + # nudge halfway toward that, clamped so a wild measurement cannot + # order a wild shelf. + target = -15.0 + delta = (target - speech_metrics["tilt_db"]) * 0.5 + fx["eq_high"] = float(max(-4.0, min(4.0, round(delta)))) + + if speech_metrics["balance"] < 0.05: + fx["mono"] = True + return fx + + def _percentile(values, pct): ordered = sorted(values) return ordered[min(len(ordered) - 1, int(len(ordered) * pct / 100))] From fe5de3cd338d3c267c2a2535f719a5f1ea83c183 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 20:15:02 -0500 Subject: [PATCH 91/99] Line the source rows up, and give FX a face every theme can draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- wavexlr/mixmatrix.py | 51 ++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index b507330..ead8e3f 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -861,6 +861,10 @@ def __init__(self, *, name, icon_name, has_level, removable=False, ) self._switch_btn.add_css_class("flat") self._switch_btn.add_css_class("circular") + # Hidden means blanked-in-place, never removed: every optional + # control keeps its column or the sliders zigzag across rows. + self._switch_btn.set_visible(True) + self._reserve(self._switch_btn, False) # Two opposing arrows rather than a radio dot: this is an action -- # "make this one live" -- not a state to read. The state is already on # the row, which is red when muted. @@ -911,17 +915,24 @@ def __init__(self, *, name, icon_name, has_level, removable=False, self._level.add_offset_value(Gtk.LEVEL_BAR_OFFSET_FULL, 1.00) inner.append(self._level) + # A text label, deliberately: no icon theme ships an "effects" + # glyph everywhere, Breeze drew the broken-image box here, and + # "FX" is the clearer button anyway. Built for EVERY row and + # merely blanked on non-capture ones, because the controls to its + # left only line up across rows if each optional widget keeps its + # column when idle. self._fx_widgets = None + self._fx_btn = Gtk.MenuButton( + label="FX", + valign=Gtk.Align.CENTER, + tooltip_text="Effects: low cut, gate, compressor, EQ, delay", + ) + self._fx_btn.add_css_class("flat") if is_capture: - fx_btn = Gtk.MenuButton( - icon_name="preferences-color-symbolic", - valign=Gtk.Align.CENTER, - tooltip_text="Effects (low cut, EQ, delay)", - ) - fx_btn.add_css_class("flat") - fx_btn.add_css_class("circular") - fx_btn.set_popover(self._build_fx_popover()) - inner.append(fx_btn) + self._fx_btn.set_popover(self._build_fx_popover()) + else: + self._reserve(self._fx_btn, False) + inner.append(self._fx_btn) if editable: edit_btn = Gtk.Button( @@ -1114,22 +1125,34 @@ def set_fx(self, fx): finally: self._fx_updating = False + @staticmethod + def _reserve(widget, shown): + """Blank a control in place instead of removing it. + + Rows line up column by column only while every optional widget + keeps its allocation; set_visible collapses the slot and shifts + everything beside it, which is how the sliders came to zigzag. + """ + widget.set_opacity(1.0 if shown else 0.0) + widget.set_sensitive(shown) + widget.set_can_target(shown) + def set_removable(self, removable, tooltip="Remove source"): - """Show or hide the remove button on a row that owns one. + """Show or blank the remove button on a row that owns one. Auto-discovered device rows are built with the button and normally - hide it: while the hardware is connected, removing its row would + blank it: while the hardware is connected, removing its row would only make it come back confusing. Unplugged, the row is clutter the user may clear — so removability follows presence. """ if self._remove_btn is not None: - self._remove_btn.set_visible(removable) - self._remove_btn.set_tooltip_text(tooltip) + self._reserve(self._remove_btn, removable) + self._remove_btn.set_tooltip_text(tooltip if removable else None) def set_group(self, group): """Show which exclusivity group this row is in, if any.""" group = (group or "").strip() - self._switch_btn.set_visible(bool(group)) + self._reserve(self._switch_btn, bool(group)) self._group_lbl.set_label(f"\u2b24 {group}" if group else "") self._group_lbl.set_visible(bool(group)) self._group_lbl.set_tooltip_text( From bc79509295e3bf83b3fb753594c337d7f6a364ea Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 21:39:53 -0500 Subject: [PATCH 92/99] Meter for the eyes that are actually looking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- wavexlr/app.py | 18 +++++++++++++----- wavexlr/meter.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index f1605ec..ef2d1bb 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -113,6 +113,13 @@ def __init__(self, **kwargs): # unseeded snapshot draws rows live rather than dead in the meantime. self._refresh_outputs() self.meter = MeterMonitor() + # Hidden in the tray, the level bars exist for nobody: pause the + # UI half of metering with the window. Stall detection rides the + # byte flow, not the dispatches, so it keeps watching regardless. + self.connect("map", + lambda *_a: setattr(self.meter, "ui_suspended", False)) + self.connect("unmap", + lambda *_a: setattr(self.meter, "ui_suspended", True)) self._stall_watch = recovery_module.StallWatch() self._meter_targets = {} self._wire_matrix_cells() @@ -718,6 +725,12 @@ def _done(result): self._apply_device_info() self._start_polling() self._start_device_watch() + # Discovery is not a launch-time-only event: a Wave plugged in + # (or back in, after its row was removed while unplugged) gets + # its row when its USB connect lands here — event-driven, + # because the pw-dump behind discovery is far too heavy for a + # periodic main-thread tick (measured: ~8% of a core at 6 s). + self._autodiscover_elgato_inputs() def _fail(e): self._connecting = False self._devs = [] @@ -1335,11 +1348,6 @@ def _stream_poll_tick(self): if check_devices: self._device_poll_countdown = self._DEVICE_POLL_EVERY self.mixer.request_capture_poll() - # Discovery is not a launch-time-only event: a Wave plugged in - # (or back in, after its row was removed while unplugged) should - # get its row now, not on the next restart. Idempotent — bound - # and already-offered nodes are skipped. - self._autodiscover_elgato_inputs() for source_id, source in list(self._sources.items()): if sources_module.kind(source) == sources_module.KIND_DEVICE: if check_devices: diff --git a/wavexlr/meter.py b/wavexlr/meter.py index 3990507..b8c29d5 100644 --- a/wavexlr/meter.py +++ b/wavexlr/meter.py @@ -32,6 +32,10 @@ class MeterMonitor: CHUNK_BYTES = 1024 def __init__(self): + # While the window is hidden the bars do not exist to anyone; + # readers keep draining (byte-flow stall detection depends on it) + # but nothing crosses to the GTK thread. + self.ui_suspended = False self._procs = {} # source_id -> Popen self._threads = {} # source_id -> Thread self._stop_flags = {} # source_id -> threading.Event @@ -130,8 +134,22 @@ def stop_all(self): for sid in list(self._procs.keys()): self.stop(sid) + # Frames of continued dispatch after the signal goes quiet, so a + # bar with peak-hold ballistics animates down before the stream of + # updates stops. ~20 frames at ~15 Hz is over a second of tail. + _QUIET = 0.004 + _TAIL_FRAMES = 20 + def _reader(self, source_id, proc, stop_flag): - """Background thread: read s16 chunks, compute peak, marshal to UI.""" + """Background thread: read s16 chunks, compute peak, marshal to UI. + + Silence is suppressed: nine meters at 15 Hz were over a hundred + main-loop wakeups and redraws a second for bars sitting at zero. + A quiet chunk still counts for the byte-flow stall detection — it + is only the UI dispatch that rests. + """ + tail = 0 + settled = False try: while not stop_flag.is_set(): data = proc.stdout.read(self.CHUNK_BYTES) @@ -141,6 +159,20 @@ def _reader(self, source_id, proc, stop_flag): n = len(data) // 2 samples = struct.unpack(f"<{n}h", data[: n * 2]) peak = max(abs(s) for s in samples) / 32768.0 + if self.ui_suspended: + settled = False + tail = 0 + continue + if peak >= self._QUIET: + tail = self._TAIL_FRAMES + settled = False + elif tail: + tail -= 1 + elif settled: + continue + else: + peak = 0.0 + settled = True GLib.idle_add(self._dispatch, source_id, peak) except (OSError, ValueError): pass From 2a85078c31bec3cf8749de6c8921f9dbc1db0923 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Sun, 30 Aug 2026 23:11:35 -0500 Subject: [PATCH 93/99] Turn the remote surface from poll-only into push, and widen it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSA2Rkwb3stBtBMJcusPwb --- wavexlr/app.py | 147 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/wavexlr/app.py b/wavexlr/app.py index ef2d1bb..e821536 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -81,6 +81,8 @@ def __init__(self, **kwargs): # during a drag into one set_cell. {(source_id, mix_id): timeout_id}. self._cell_debounce_ids = {} self._fx_debounce_ids = {} + self._remote_levels = {} + self._push_id = None # One-shot re-read of the routing after a mix output change settles. self._output_refresh_id = None self._sources = sources_module.load_seeded() @@ -915,6 +917,7 @@ def _set_row_mute_from_hardware(self, dev, muted): self._enforce_exclusive_group(source_id) sources_module.save(self._sources) self._notify_tray() + self._push_remote_state() def _on_poll_result(self, result): self._poll_busy = False @@ -1124,10 +1127,11 @@ def _refresh_mix_meter(self, mix_id, mix): if self._meter_targets.get(key) == sink and self.meter.running(key): return self._meter_targets[key] = sink - self.meter.start( - key, sink, - lambda level, mid=mix_id: self.matrix.set_mix_level(mid, level), - capture_sink=True) + def _on_mix_level(level, mid=mix_id): + self._remote_levels[f"mix:{mid}"] = round(float(level), 4) + self.matrix.set_mix_level(mid, level) + + self.meter.start(key, sink, _on_mix_level, capture_sink=True) def _stop_mix_meter(self, mix_id): key = f"mix:{mix_id}" @@ -1496,10 +1500,20 @@ def _set_source_waiting(self, source_id, waiting, hint="Waiting for audio"): cell.set_waiting(waiting, hint) def _set_source_level(self, source_id, level): + self._remote_levels[f"src:{source_id}"] = round(float(level), 4) cell = self.matrix.source(source_id) if cell is not None: cell.set_level(level) + def remote_levels(self): + """Every live meter's latest peak, as JSON, for the `levels` action. + + Fed by the same callbacks that move the bars — publishing costs a + dict write per meter frame, and reading is one Describe. A remote + polls this only while a dial with a meter is actually on screen. + """ + return json.dumps(self._remote_levels) + def _on_add_source_clicked(self, _matrix): dialog = AddSourceDialog( exclude_nodes=self._bound_capture_nodes(), @@ -1687,6 +1701,58 @@ def _on_source_level_changed(self, _cell, volume, source_id): source_id, volume, self._sources.get(source_id, {}).get("muted", False)) sources_module.save(self._sources) + def toggle_fx(self, source_id, effect): + """Flip one effect from the remote surface. Returns the new value. + + The toggles a deck key can honestly draw as an LED: lowcut flips + between off and 80 Hz (the popover still offers 120), gate, comp + and mono flip their booleans. Threshold-shaped settings are not + toggles and stay with the popover and the calibrator. + """ + source = self._sources.get(source_id) + if source is None or sources_module.kind(source) \ + != sources_module.KIND_DEVICE: + return None + fx = sources_module.fx(source) + if effect == "lowcut": + fx["lowcut"] = 0 if fx["lowcut"] else 80 + new = fx["lowcut"] + elif effect in ("gate", "comp", "mono"): + fx[effect] = not fx[effect] + new = fx[effect] + else: + return None + source["fx"] = fx + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_fx(fx) + sources_module.save(self._sources) + self.mixer.set_sources(self._sources) + self._push_remote_state() + return new + + def _push_remote_state(self): + """Refresh the published states soon, once, however many changes. + + This is what turns the remote surface from poll-only into push: + set_state on a stateful action emits org.gtk.Actions.Changed, so a + subscribed deck redraws the moment the mixer moves — from this + window, the hardware button, a scene, anything — instead of on a + timer. Debounced because a slider drag is dozens of changes and + one Changed per gesture is what a subscriber wants. + """ + if self._push_id is not None: + return + + def _fire(): + self._push_id = None + app = self.get_application() + if app is not None: + app.push_states() + return GLib.SOURCE_REMOVE + + self._push_id = GLib.timeout_add(150, _fire) + def _on_fx_autotune(self, cell, source_id): """Two guided measurements, then gate and compressor set to fit. @@ -1810,6 +1876,7 @@ def _apply(sid=source_id, c=cell): source["fx"] = c.fx_settings() sources_module.save(self._sources) self.mixer.set_sources(self._sources) + self._push_remote_state() return GLib.SOURCE_REMOVE self._fx_debounce_ids[source_id] = GLib.timeout_add( @@ -1823,6 +1890,7 @@ def _on_source_mute_toggled(self, _cell, muted, source_id): self._enforce_exclusive_group(source_id) sources_module.save(self._sources) self._notify_tray() + self._push_remote_state() def _on_group_sources_clicked(self, _matrix, dragged_id, target_id): """Put the dragged source in the target's group. @@ -1896,6 +1964,7 @@ def set_source_volume(self, source_id, level): self.mixer.set_source_level(source_id, level, source.get("muted", False)) sources_module.save(self._sources) + self._push_remote_state() return True # How each protocol profile's hardware names its capture node. Used to @@ -1980,6 +2049,7 @@ def toggle_source_mute(self, source_id): self._enforce_exclusive_group(source_id) sources_module.save(self._sources) self._notify_tray() + self._push_remote_state() return muted def set_cell_volume(self, source_id, mix_id, volume): @@ -1999,6 +2069,7 @@ def set_cell_volume(self, source_id, mix_id, volume): cell.set_volume(volume) self.mixer.set_cell(source_id, mix_id, volume, current["muted"]) self._refresh_mix_emptiness() + self._push_remote_state() return True def toggle_cell_mute(self, source_id, mix_id): @@ -2012,6 +2083,7 @@ def toggle_cell_mute(self, source_id, mix_id): cell.set_muted(muted) self.mixer.set_cell(source_id, mix_id, current["volume"], muted) self._refresh_mix_emptiness() + self._push_remote_state() return muted # --- Scenes ----------------------------------------------------------- @@ -2031,6 +2103,7 @@ def save_scene(self, name): payload["hardware"] = hardware sid = scenes_module.put(name.strip(), payload) self._rebuild_scene_menu() + self._push_remote_state() return sid def apply_scene(self, sid): @@ -2076,12 +2149,14 @@ def apply_scene(self, sid): self._refresh_mix_emptiness() if skipped: logging.info("scene %s: skipped %s", sid, ", ".join(skipped)) + self._push_remote_state() return skipped def delete_scene(self, sid): removed = scenes_module.remove(sid) if removed: self._rebuild_scene_menu() + self._push_remote_state() return removed def _hardware_scene_state(self): @@ -2210,6 +2285,9 @@ def remote_snapshot(self): "muted": bool(source.get("muted", False)), "group": sources_module.group(source), "kind": sources_module.kind(source), + # The DSP settings ride along so a remote control can + # draw an fx toggle's LED without a second call. + "fx": sources_module.fx(source), } for sid, source in self._sources.items() ], @@ -2271,6 +2349,7 @@ def _on_switch_source_clicked(self, _matrix, source_id): self._sync_hw_mute(target, False) self._enforce_exclusive_group(target_id) sources_module.save(self._sources) + self._push_remote_state() def _enforce_exclusive_group(self, active_id): """Leave only one source in a group unmuted. @@ -2518,6 +2597,17 @@ def _register_remote_actions(self): scenes_state.connect("activate", self._action_refresh_scenes) self.add_action(scenes_state) + levels = Gio.SimpleAction.new_stateful( + "levels", None, GLib.Variant("s", "{}"), + ) + levels.connect("activate", self._action_refresh_levels) + self.add_action(levels) + + toggle_fx = Gio.SimpleAction.new( + "toggle-fx", GLib.VariantType.new("(ss)")) + toggle_fx.connect("activate", self._action_toggle_fx) + self.add_action(toggle_fx) + def _action_switch_group(self, _action, parameter): if self._window is None or parameter is None: return @@ -2624,6 +2714,55 @@ def _action_refresh_scenes(self, action, _parameter): except Exception: # noqa: BLE001 logging.exception("scenes failed") + def _action_refresh_levels(self, action, _parameter): + """State: every live meter's latest peak, {src:|mix:: 0..1}.""" + if self._window is None: + return + try: + action.set_state(GLib.Variant("s", self._window.remote_levels())) + except Exception: # noqa: BLE001 + logging.exception("levels failed") + + def _action_toggle_fx(self, _action, parameter): + if self._window is None or parameter is None: + return + source_id, effect = parameter.unpack() + try: + self._window.toggle_fx(source_id, effect) + except Exception: # noqa: BLE001 + logging.exception("toggle-fx failed") + + def push_states(self): + """Recompute every published state so subscribers hear Changed. + + The read-only actions were poll-only — Activate refreshed, Describe + read. Pushing the same states when the mixer actually moves lets a + remote subscribe instead of poll; the poll contract still holds for + clients that prefer it. Levels are deliberately NOT pushed: they + move fifteen times a second per meter, and a bus broadcast at that + rate serves nobody — a remote polls them only while a meter-bearing + control is on screen. + """ + if self._window is None: + return + for name, value in ( + ("snapshot", self._window.remote_snapshot()), + ("scenes", json.dumps(self._window.scene_names())), + ): + action = self.lookup_action(name) + if action is not None: + try: + action.set_state(GLib.Variant("s", value)) + except Exception: # noqa: BLE001 + logging.exception("push of %s failed", name) + groups = self.lookup_action("source-groups") + if groups is not None: + try: + groups.set_state( + GLib.Variant("as", self._window.source_groups())) + except Exception: # noqa: BLE001 + logging.exception("push of source-groups failed") + def do_command_line(self, command_line): options = command_line.get_options_dict() if options.contains("hide"): From cfd6804dcde0b417f0cfdad88a7179fa148f0be1 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Tue, 1 Sep 2026 17:56:12 -0500 Subject: [PATCH 94/99] Let the Wave drive, and watch for the faults the keepalive can't see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H --- CHANGELOG.md | 17 ++ tests/test_health.py | 195 +++++++++++++ wavexlr/daemon.py | 8 + wavexlr/health.py | 406 ++++++++++++++++++++++++++ wireplumber/51-openwave-wave-xlr.conf | 11 + 5 files changed, 637 insertions(+) create mode 100644 tests/test_health.py create mode 100644 wavexlr/health.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b24954..3814b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ versions are git tags (see [Releases](../../releases)). ## [Unreleased] ### Added +- **Watchdogs for faults every byte-level check passes**: the daemon + now runs two slow health checks alongside the capture keepalive. A + glitch watch reads the profiler's per-node xrun counter (via + `pw-top`) and, on sustained accumulation — the robotic-mic fault, + measured at ~23 xruns/s — cycles the card to reopen the capture. A + stall watch compares each output sink's kernel `hw_ptr` between + windows and, when a running sink's hardware stops consuming — the + silent-output fault a WirePlumber restart can leave behind — suspends + and resumes the sink to reopen its PCM. Both remedies are + rate-limited (two attempts, 60 s cooldown, budget refilled on + recovery) so a genuinely broken device is left alone to be noticed. +- **The Wave wins the graph-driver election**: the WirePlumber conf now + pins `priority.driver = 2500` on Wave nodes. All ALSA capture nodes + default to 2100 and a tie falls to the lowest object id, which handed + the graph clock to a wireless headset dongle whose jittery delivery + made the Wave's follower DLL resync ~23×/s — audibly robotic. The + Wave's wired iso clock is the stable one; let it drive. - **Multiple Wave devices at once**: every connected Wave — two of the same model included — is opened, polled at 10 Hz and ALSA-synced; a Device dropdown in the sidebar picks which one the controls drive, a diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..a2b0396 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,195 @@ +"""The watchdogs for faults every byte-level check passes. + +Both decisions are pure so they can be tested without a sound card, and +both mistakes are silent: missing the fault leaves robotic or inaudible +audio that every layer reports as healthy, and acting too eagerly cycles +hardware underneath someone who is using it. +""" + +import unittest + +from wavexlr import health + + +DOCK = ("alsa_input.usb-Elgato_Systems_Elgato_XLR_Dock_A8A9A40411NOP9-00" + ".mono-fallback") +SINK = "alsa_output.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.analog-stereo" + + +# Real pw-top output shapes, including the quirks the parser must +# survive: the header, '---' placeholder rows, '???' warmup ratios, the +# FORMAT column being present, absent, or three tokens wide, and the +# first iteration printing zeros before the profiler warms up. +PW_TOP_OUTPUT = f"""\ +S ID QUANT RATE WAIT BUSY W/Q B/Q ERR FORMAT NAME +C 73 0 0 --- --- --- --- 0 {DOCK} +R 73 0 0 0.0us 0.0us ??? ??? 0 S24LE 1 48000 {DOCK} +S ID QUANT RATE WAIT BUSY W/Q B/Q ERR FORMAT NAME +R 73 0 0 12.3us 4.2us 0.00 0.00 30367 S24LE 1 48000 + {DOCK} +R 199 0 0 1.2us 7.4us 0.00 0.00 5 F32P 1 0 + openwave_fx_2f216c26f5e3 +""" + + +class ParsingPwTop(unittest.TestCase): + def test_the_last_iteration_wins(self): + counts = health._parse_pw_top(PW_TOP_OUTPUT) + self.assertEqual(counts[DOCK], 30367) + + def test_every_printed_node_is_counted(self): + counts = health._parse_pw_top(PW_TOP_OUTPUT) + self.assertEqual(counts["openwave_fx_2f216c26f5e3"], 5) + + def test_headers_and_placeholder_rows_do_not_crash_or_count(self): + counts = health._parse_pw_top(PW_TOP_OUTPUT) + self.assertNotIn("NAME", counts) + self.assertNotIn("FORMAT", counts) + + +class GlitchDeciding(unittest.TestCase): + def setUp(self): + self.w = health.GlitchWatch( + threshold=50, confirm=2, cooldown_seconds=60, max_attempts=2) + + def feed(self, counts, start=0.0, step=10.0): + verdicts = [] + for i, c in enumerate(counts): + verdicts.append(self.w.observe(DOCK, c, start + i * step)) + return verdicts + + def test_first_sight_only_baselines(self): + """A node first seen with a huge historical count has not been + observed glitching — the count could be weeks old.""" + self.assertEqual(self.feed([61994]), [False]) + self.assertFalse(self.w.glitching(DOCK)) + + def test_a_flat_counter_is_healthy(self): + self.feed([100, 100, 102, 102]) + self.assertFalse(self.w.glitching(DOCK)) + + def test_the_robotic_fault_is_confirmed_in_two_windows(self): + # ~23 xruns/s over 10 s windows, as measured on hardware. + self.feed([0, 230, 460]) + self.assertTrue(self.w.glitching(DOCK)) + self.assertTrue(self.w.should_recover(DOCK, now=100.0)) + + def test_one_burst_is_an_event_not_a_state(self): + """A single bad window (game launch, compile) must not cycle a + card someone is speaking into.""" + self.feed([0, 230, 235]) + self.assertFalse(self.w.glitching(DOCK)) + + def test_a_wireless_followers_own_jitter_stays_below_threshold(self): + # The Arctis was observed bursting 23 in one window while healthy. + self.feed([238, 261, 284]) + self.assertFalse(self.w.glitching(DOCK)) + + def test_a_recreated_node_baselines_instead_of_panicking(self): + """The profiler counter resets when a node is recreated; the + shrink must start a fresh baseline, not be treated as glitching + or as a 4-billion-xrun window.""" + self.feed([30000, 30230, 5]) + self.assertEqual(self.w._streak[DOCK], 0) + + def test_attempts_are_capped(self): + self.feed([0, 230, 460]) + self.w.record_attempt(DOCK, 20.0) + self.feed([690, 920], start=100.0) + self.w.record_attempt(DOCK, 120.0) + self.feed([1150, 1380], start=300.0) + self.assertFalse(self.w.should_recover(DOCK, now=400.0)) + + def test_cooldown_blocks_a_rapid_second_attempt(self): + self.feed([0, 230, 460]) + self.w.record_attempt(DOCK, 20.0) + self.feed([690], start=30.0) + self.assertFalse(self.w.should_recover(DOCK, now=30.0)) + self.assertTrue(self.w.should_recover(DOCK, now=90.0)) + + def test_a_clean_window_refills_the_budget(self): + """Recovery is per incident, not per process lifetime.""" + self.feed([0, 230, 460]) + self.w.record_attempt(DOCK, 20.0) + self.w.record_attempt(DOCK, 90.0) + self.feed([461, 462], start=100.0) # clean: recovered + self.feed([700, 940], start=200.0) # a fresh incident + self.assertTrue(self.w.should_recover(DOCK, now=300.0)) + + def test_forget_starts_clean(self): + self.feed([0, 230, 460]) + self.w.record_attempt(DOCK, 20.0) + self.w.forget(DOCK) + self.assertEqual(self.feed([9000]), [False]) + self.assertFalse(self.w.glitching(DOCK)) + + +class SinkStallDeciding(unittest.TestCase): + def setUp(self): + self.w = health.SinkStallWatch(cooldown_seconds=60, max_attempts=2) + + def test_an_advancing_pointer_is_healthy(self): + self.w.observe(SINK, True, 1000, "RUNNING", 0.0) + stalled = self.w.observe(SINK, True, 49000, "RUNNING", 10.0) + self.assertFalse(stalled) + + def test_the_first_observation_only_baselines(self): + """A sink that just started gets a full window before being + judged, even though its pointer has no history.""" + self.assertFalse(self.w.observe(SINK, True, 1000, "RUNNING", 0.0)) + self.assertFalse(self.w.should_recover(SINK, now=0.0)) + + def test_a_static_pointer_while_running_is_a_stall(self): + self.w.observe(SINK, True, 1000, "RUNNING", 0.0) + stalled = self.w.observe(SINK, True, 1000, "RUNNING", 10.0) + self.assertTrue(stalled) + self.assertTrue(self.w.should_recover(SINK, now=10.0)) + + def test_an_idle_sink_holding_still_is_not_a_stall(self): + """Suspended and idle sinks legitimately stop consuming; cycling + one would wake hardware nobody is playing to.""" + self.w.observe(SINK, False, 1000, "SETUP", 0.0) + stalled = self.w.observe(SINK, False, 1000, "SETUP", 10.0) + self.assertFalse(stalled) + + def test_xrun_state_is_an_immediate_stall(self): + stalled = self.w.observe(SINK, True, 1000, "XRUN", 0.0) + self.assertTrue(stalled) + + def test_a_missing_proc_entry_is_not_ours_to_judge(self): + self.w.observe(SINK, True, None, None, 0.0) + stalled = self.w.observe(SINK, True, None, None, 10.0) + self.assertFalse(stalled) + + def test_a_recycle_does_not_feed_its_own_reset_back_as_a_stall(self): + """suspend/resume resets hw_ptr to zero; comparing the next + window against the pre-recycle value would misread recovery.""" + self.w.observe(SINK, True, 1000, "RUNNING", 0.0) + self.w.observe(SINK, True, 1000, "RUNNING", 10.0) + self.w.record_attempt(SINK, 10.0) + self.assertFalse(self.w.observe(SINK, True, 0, "RUNNING", 20.0)) + + def test_attempts_are_capped_and_cooled_down(self): + self.w.observe(SINK, True, 1000, "RUNNING", 0.0) + self.w.observe(SINK, True, 1000, "RUNNING", 10.0) + self.w.record_attempt(SINK, 10.0) + self.w.observe(SINK, True, 500, "RUNNING", 20.0) + self.w.observe(SINK, True, 500, "RUNNING", 30.0) + self.assertFalse(self.w.should_recover(SINK, now=30.0)) # cooling + self.assertTrue(self.w.should_recover(SINK, now=80.0)) + self.w.record_attempt(SINK, 80.0) + self.w.observe(SINK, True, 500, "RUNNING", 150.0) + self.w.observe(SINK, True, 500, "RUNNING", 160.0) + self.assertFalse(self.w.should_recover(SINK, now=300.0)) # spent + + def test_movement_refills_the_budget(self): + self.w.observe(SINK, True, 1000, "RUNNING", 0.0) + self.w.observe(SINK, True, 1000, "RUNNING", 10.0) + self.w.record_attempt(SINK, 10.0) + self.w.record_attempt(SINK, 80.0) + self.w.observe(SINK, True, 2000, "RUNNING", 90.0) # recovered + self.w.observe(SINK, True, 50000, "RUNNING", 100.0) + self.w.observe(SINK, True, 50000, "RUNNING", 110.0) # new stall + self.assertTrue(self.w.should_recover(SINK, now=200.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/daemon.py b/wavexlr/daemon.py index e0e784b..85da192 100644 --- a/wavexlr/daemon.py +++ b/wavexlr/daemon.py @@ -6,6 +6,7 @@ import sys from .audio import AudioManager +from .health import HealthMonitor logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") log = logging.getLogger("openwave.daemon") @@ -31,8 +32,15 @@ def on_status(present, healthy, state): mgr = AudioManager(on_status_change=on_status) mgr.start() + # Slow watchdogs for the faults the keepalive cannot see: xrun + # accumulation (robotic capture) and a running sink whose hardware + # has stopped consuming (silent output). + health = HealthMonitor() + health.start() + def shutdown(sig, frame): log.info("Shutting down") + health.stop() mgr.stop() sys.exit(0) diff --git a/wavexlr/health.py b/wavexlr/health.py new file mode 100644 index 0000000..241a4f8 --- /dev/null +++ b/wavexlr/health.py @@ -0,0 +1,406 @@ +"""Watchdogs for the two faults that pass every existing health check. + +Both were observed on real hardware on the same day, and both are +invisible to the capture keepalive: bytes flow, nothing is muted, every +node reports "running" — and the audio is still wrong. + +Glitchy capture: the Wave's ALSA node accumulates xruns continuously +(~23/s when observed) because the graph clock it follows can't be +tracked — concretely, a wireless headset dongle winning the driver +election on an object-id tiebreak and feeding the Wave's follower DLL a +jittery clock it resynced against forever. The audible result is a +robotic, granular microphone. The wireplumber conf now pins the Wave's +`priority.driver` above the default so it wins the election, but the +watchdog stays: any future source of sustained xruns sounds the same, +and the counter is the only place it shows. + +Stalled output: a sink's PipeWire node runs, the graph delivers real +samples to it, volume and mute read fine — and the ALSA device behind +it consumes nothing, so the hardware plays silence. Observed after a +WirePlumber restart recreated the device nodes. The graph cannot see +this at all; only the kernel's hw_ptr shows it, by not moving. The +remedy is to close and reopen the PCM, which suspending and resuming +the sink does. + +Detection is separated from the acting on it, StallWatch-style, so the +decisions can be tested without a sound card: every input is a name, a +number, or a bool. + +xrun counts come from `pw-top`, because that is the only place PipeWire +exports the profiler's per-node xrun counter (`pw-dump` carries no such +field). Three iterations are requested because the count is verified to +need them: the first two print placeholder zeros while the profiler +warms up — measured directly, 2 iterations read 0 where 3 read the true +count — and the parser takes the last value printed per node. +""" + +import logging +import os +import re +import subprocess +import threading +import time + +from . import recovery +from .audio import _pw_dump + +log = logging.getLogger("wavexlr.health") + +# Seconds between health checks. Sampling pw-top blocks for about a +# second of profiler iterations, so this is deliberately much slower +# than the keepalive watchdog's 1 s tick. +CHECK_INTERVAL = 10.0 + +# xruns per check window that count as glitching. A healthy node logs a +# handful at stream start and then stays flat; the robotic-mic fault ran +# at ~230 per window. A wireless follower absorbing its own jitter was +# observed bursting ~23 in one window, and must not trip this. +GLITCH_XRUNS_PER_CHECK = 50 + +# Consecutive glitchy windows before acting. One window can be a system +# hiccup (game launch, compile); two in a row is a state, not an event. +GLITCH_CONFIRM_CHECKS = 2 + +# Rate limits for both remedies, matching recovery.py's reasoning: a +# failed recovery must not become a loop, and a device that stays broken +# through two attempts should be left alone to be noticed. +COOLDOWN_SECONDS = 60.0 +MAX_ATTEMPTS = 2 + + +# --- sampling seams (each one shell or /proc; patched out in tests) --- + +def sample_xruns(): + """Per-node cumulative xrun counts, from pw-top's profiler view. + + Returns {node.name: xruns} for every node pw-top prints. The counter + is cumulative for the node's lifetime and resets when the node is + recreated; GlitchWatch handles the reset. + """ + try: + r = subprocess.run( + ["pw-top", "--batch-mode", "--iterations", "3"], + capture_output=True, text=True, timeout=15, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return {} + if r.returncode != 0: + return {} + return _parse_pw_top(r.stdout) + + +def _parse_pw_top(text): + counts = {} + for line in text.splitlines(): + tokens = line.split() + # S ID QUANT RATE WAIT BUSY W/Q B/Q ERR [FORMAT...] NAME + if len(tokens) < 10 or not tokens[8].isdigit(): + continue + # Later iterations overwrite earlier ones: last wins. + counts[tokens[-1]] = int(tokens[8]) + return counts + + +def read_playback_status(card, device, subdevice): + """(hw_ptr, state) of one ALSA playback substream, or (None, None). + + hw_ptr is the DMA position the hardware has consumed up to. A + running playback stream always advances it — even one playing pure + silence — which is what makes a static pointer a hardware verdict + rather than a signal-level one. + """ + path = (f"/proc/asound/card{card}/pcm{device}p/" + f"sub{subdevice}/status") + try: + with open(path) as f: + text = f.read() + except OSError: + return None, None + ptr = re.search(r"^hw_ptr\s*:\s*(\d+)", text, re.MULTILINE) + state = re.search(r"^state:\s*(\S+)", text, re.MULTILINE) + return (int(ptr.group(1)) if ptr else None, + state.group(1) if state else None) + + +def recycle_sink(sink_name): + """Close and reopen a sink's PCM by suspending and resuming it.""" + for flag in ("1", "0"): + try: + r = subprocess.run( + ["pactl", "suspend-sink", sink_name, flag], + capture_output=True, timeout=5, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return False + if r.returncode != 0: + return False + if flag == "1": + time.sleep(1.0) + return True + + +def snapshot_graph(): + """One pw-dump distilled to what the health checks need. + + Returns (wave_captures, watched_sinks) where wave_captures is the + list of Wave capture node names and watched_sinks maps each ALSA + sink some openwave loop-out targets to its + {running, card, device, subdevice}. + """ + from .audio import SOURCE_MATCHES + dump = _pw_dump() + captures = [] + sinks = {} + targets = set() + for obj in dump: + if obj.get("type") != "PipeWire:Interface:Node": + continue + props = obj.get("info", {}).get("props", {}) + name = props.get("node.name", "") + if name.startswith(SOURCE_MATCHES): + captures.append(name) + if (name.startswith("openwave_loop_out") + and not name.endswith("_cap")): + target = props.get("target.object") + if isinstance(target, str) and target.startswith("alsa_output."): + targets.add(target) + for obj in dump: + if obj.get("type") != "PipeWire:Interface:Node": + continue + info = obj.get("info", {}) + props = info.get("props", {}) + name = props.get("node.name", "") + if name not in targets: + continue + try: + sinks[name] = { + "running": info.get("state") == "running", + "card": int(props["alsa.card"]), + "device": int(props["alsa.device"]), + "subdevice": int(props.get("alsa.subdevice", 0)), + } + except (KeyError, TypeError, ValueError): + continue + return captures, sinks + + +# --- decisions --- + +class GlitchWatch: + """Decides when a capture node's xrun counter means glitching audio. + + Fed one cumulative count per check window. The counter resets to + zero when a node is recreated; a shrinking count therefore starts a + fresh baseline instead of celebrating a negative delta. + """ + + def __init__(self, threshold=GLITCH_XRUNS_PER_CHECK, + confirm=GLITCH_CONFIRM_CHECKS, + cooldown_seconds=COOLDOWN_SECONDS, + max_attempts=MAX_ATTEMPTS): + self.threshold = threshold + self.confirm = confirm + self.cooldown_seconds = cooldown_seconds + self.max_attempts = max_attempts + self._prev = {} # node_name -> last cumulative count + self._streak = {} # node_name -> consecutive bad windows + self._attempts = {} # node_name -> remedies spent + self._last_attempt = {} # node_name -> monotonic time + + def forget(self, node_name): + for d in (self._prev, self._streak, + self._attempts, self._last_attempt): + d.pop(node_name, None) + + def observe(self, node_name, xruns, now): + """Account one window; True when that window was glitchy.""" + prev = self._prev.get(node_name) + self._prev[node_name] = xruns + if prev is None or xruns < prev: + # First sight, or the node was recreated: baseline only. + self._streak[node_name] = 0 + return False + if xruns - prev >= self.threshold: + self._streak[node_name] = self._streak.get(node_name, 0) + 1 + return True + # A clean window after a remedy is the recovery signal: the + # budget refills for the next incident rather than staying + # spent forever. + self._streak[node_name] = 0 + self._attempts.pop(node_name, None) + return False + + def glitching(self, node_name): + return self._streak.get(node_name, 0) >= self.confirm + + def should_recover(self, node_name, now): + if not self.glitching(node_name): + return False + if self._attempts.get(node_name, 0) >= self.max_attempts: + return False + last = self._last_attempt.get(node_name) + if last is not None and now - last < self.cooldown_seconds: + return False + return True + + def record_attempt(self, node_name, now): + self._attempts[node_name] = self._attempts.get(node_name, 0) + 1 + self._last_attempt[node_name] = now + + +class SinkStallWatch: + """Decides when a running sink's hardware has stopped consuming. + + Fed (running, hw_ptr, alsa_state) per check window. A stall is a + pointer that did not move between two windows while the node claims + to be running, or the kernel reporting the stream in XRUN. The first + observation of a sink only baselines the pointer — a sink that just + started gets a full window before being judged. + """ + + def __init__(self, cooldown_seconds=COOLDOWN_SECONDS, + max_attempts=MAX_ATTEMPTS): + self.cooldown_seconds = cooldown_seconds + self.max_attempts = max_attempts + self._prev_ptr = {} # sink_name -> last hw_ptr + self._stalled = {} # sink_name -> bool + self._attempts = {} # sink_name -> remedies spent + self._last_attempt = {} # sink_name -> monotonic time + + def forget(self, sink_name): + for d in (self._prev_ptr, self._stalled, + self._attempts, self._last_attempt): + d.pop(sink_name, None) + + def observe(self, sink_name, running, hw_ptr, alsa_state, now): + """Account one window; True when the sink is stalled.""" + prev = self._prev_ptr.get(sink_name) + self._prev_ptr[sink_name] = hw_ptr + if not running or hw_ptr is None: + # Idle and suspended sinks legitimately hold still, and a + # sink whose /proc entry vanished is not ours to judge. + self._stalled[sink_name] = False + self._prev_ptr.pop(sink_name, None) + return False + if alsa_state == "XRUN": + self._stalled[sink_name] = True + return True + if prev is None: + self._stalled[sink_name] = False + return False + stalled = hw_ptr == prev + self._stalled[sink_name] = stalled + if not stalled: + self._attempts.pop(sink_name, None) + return stalled + + def should_recover(self, sink_name, now): + if not self._stalled.get(sink_name): + return False + if self._attempts.get(sink_name, 0) >= self.max_attempts: + return False + last = self._last_attempt.get(sink_name) + if last is not None and now - last < self.cooldown_seconds: + return False + return True + + def record_attempt(self, sink_name, now): + self._attempts[sink_name] = self._attempts.get(sink_name, 0) + 1 + self._last_attempt[sink_name] = now + # The recycle itself resets the pointer; don't let the next + # window compare against a pre-recycle value. + self._prev_ptr.pop(sink_name, None) + + +# --- orchestration --- + +class HealthMonitor: + """Runs both watchdogs on a slow loop; remedies are rate-limited. + + The glitch remedy is recovery.cycle_card — close and reopen the + device — because the fault lives at the ALSA/clock layer where + restarting a stream changes nothing. The stall remedy is a sink + suspend/resume, verified on hardware to restart a wedged PCM. + """ + + def __init__(self): + self._running = False + self._thread = None + self.glitch = GlitchWatch() + self.stall = SinkStallWatch() + self._known_captures = set() + self._known_sinks = set() + + def start(self): + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def stop(self): + self._running = False + if self._thread: + self._thread.join(timeout=3) + + def check_once(self, now=None): + """One pass over both watchdogs; separate so tests can drive it.""" + now = time.monotonic() if now is None else now + captures, sinks = snapshot_graph() + + # A node that went away starts clean when it comes back — + # replugging is itself part of several failure stories, and must + # not inherit a spent remedy budget or a stale counter baseline. + for gone in self._known_captures - set(captures): + self.glitch.forget(gone) + for gone in self._known_sinks - set(sinks): + self.stall.forget(gone) + self._known_captures = set(captures) + self._known_sinks = set(sinks) + + if captures: + counts = sample_xruns() + for name in captures: + if name not in counts: + continue + if not self.glitch.observe(name, counts[name], now): + continue + log.warning( + "%s accumulated xruns this window — the capture is " + "glitching (robotic audio) while every byte-level " + "check passes", name) + if self.glitch.should_recover(name, now): + self.glitch.record_attempt(name, now) + card = recovery.card_name_for(name) + if card and recovery.cycle_card(card): + log.warning( + "cycled %s to reopen the glitching capture; " + "if this recurs, another node is winning the " + "graph-driver election over the Wave — check " + "priority.driver in the wireplumber conf", + card) + + for name, sink in sinks.items(): + ptr, state = read_playback_status( + sink["card"], sink["device"], sink["subdevice"]) + if not self.stall.observe(name, sink["running"], ptr, + state, now): + continue + log.warning( + "%s claims to be running but its hardware pointer is " + "not moving — the graph is delivering audio the device " + "is not playing", name) + if self.stall.should_recover(name, now): + self.stall.record_attempt(name, now) + if recycle_sink(name): + log.warning( + "suspended and resumed %s to reopen its PCM", + name) + + def _run(self): + while self._running: + try: + self.check_once() + except Exception as e: + log.error("health monitor error: %s", e) + time.sleep(CHECK_INTERVAL) diff --git a/wireplumber/51-openwave-wave-xlr.conf b/wireplumber/51-openwave-wave-xlr.conf index ecf80d2..836fd57 100644 --- a/wireplumber/51-openwave-wave-xlr.conf +++ b/wireplumber/51-openwave-wave-xlr.conf @@ -14,6 +14,16 @@ # if they want a different rate, # which is what we want — the # *device* never changes) +# priority.driver = 2500 → win graph-driver election. ALSA +# capture nodes all default to +# 2100, and a tie falls to the +# lowest object id — which handed +# the graph clock to a wireless +# headset dongle whose jittery +# delivery made the Wave's follower +# DLL resync ~23×/s (robotic mic). +# The Wave's wired iso clock is the +# stable one; let it drive. # # Pairs with wavexlr-audio's data-flow watchdog: even if a wedge slips # through, the daemon detects it (no bytes for >3s) and recycles the @@ -30,6 +40,7 @@ monitor.alsa.rules = [ session.suspend-timeout-seconds = 0 node.pause-on-idle = false audio.rate = 48000 + priority.driver = 2500 } } } From 3237103ed618854d44548301ef04f10ad24a343b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Tue, 1 Sep 2026 17:56:19 -0500 Subject: [PATCH 95/99] =?UTF-8?q?Ignore=20the=20comparison=20notes=20?= =?UTF-8?q?=E2=80=94=20local=20scratch=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 94829cb..0a37fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc *.pyo .vscode/ +docs/comparison.md From 84127916cb6aa6dd40748eb44478a189fbae09f2 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Tue, 1 Sep 2026 20:32:44 -0500 Subject: [PATCH 96/99] Let the device's mute button and its row tell one story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H --- CHANGELOG.md | 13 +++ tests/support.py | 1 + tests/test_hw_mute_sync.py | 167 +++++++++++++++++++++++++++++++++++++ wavexlr/app.py | 60 ++++++++++++- wavexlr/mixer.py | 68 +++++++++++++++ wavexlr/sources.py | 45 ++++++++++ 6 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 tests/test_hw_mute_sync.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3814b2d..14ddba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ versions are git tags (see [Releases](../../releases)). ## [Unreleased] ### Added +- **Device mute buttons and the mixer tell one story**: a capture + device's own ALSA-level mute (a headset's hardware mute button, a + toggle in another mixer) now syncs with its matrix row in both + directions. Muting a device row also mutes the source via pactl — + the same mirror open Waves already get over USB — and the ~6 s + capture poll watches for the device's mute changing outside the + mixer and moves the row to match, on edges only so a poll can never + flip a fresh click back. At first sight the row wins instead — a + group hand-over's muted backup stays muted, and a stale mute a + session-manager restart left on the device (the "mic isn't working" + trap) is cleared to match the live row. The capture-stall watchdog + also learned that a muted microphone is silent on purpose and no + longer considers cycling its card. - **Watchdogs for faults every byte-level check passes**: the daemon now runs two slow health checks alongside the capture keepalive. A glitch watch reads the profiler's per-node xrun counter (via diff --git a/tests/support.py b/tests/support.py index cc1bdfc..d1fe5a7 100644 --- a/tests/support.py +++ b/tests/support.py @@ -52,6 +52,7 @@ def bare_mixer(**attrs): mx._procs = {} mx._intakes = set() mx._live_captures = frozenset() + mx._capture_mutes = {} mx.mic = None mx.hp = None mx._started = False diff --git a/tests/test_hw_mute_sync.py b/tests/test_hw_mute_sync.py new file mode 100644 index 0000000..59266bb --- /dev/null +++ b/tests/test_hw_mute_sync.py @@ -0,0 +1,167 @@ +"""A device's own mute and its matrix row, kept telling the same story. + +A headset's hardware mute button flips the source's ALSA mute and nothing +downstream can tell that silence from a quiet room: the row reads live over +a microphone delivering nothing. The reverse lie is a muted row over a +device whose own state says on-air. hw_mute_changes decides when the row +follows the device; the pactl plumbing carries the row back to the device. +""" + +import json +import unittest +from unittest import mock + +from wavexlr import mixer as mixer_mod +from wavexlr import sources + + +def device(source_id, node, muted=False): + return {"id": source_id, "kind": sources.KIND_DEVICE, + "name": source_id, "node_name": node, "muted": muted} + + +class Deciding(unittest.TestCase): + def test_first_sight_mismatch_writes_the_row_to_the_device(self): + """The muted-headset-at-startup trap: the row is deliberate mixer + state, the device's mute may be a session manager's stale restore, + so the row wins the first look -- which unmutes the device here.""" + srcs = {"hs": device("hs", "alsa_input.headset")} + seen, moves, writes = sources.hw_mute_changes( + {}, {"alsa_input.headset": True}, srcs) + self.assertEqual(moves, []) + self.assertEqual(writes, [("alsa_input.headset", False)]) + # Observed value remembered, not the written one. + self.assertEqual(seen, {"alsa_input.headset": True}) + + def test_first_sight_keeps_a_grouped_backup_muted(self): + """A row muted by a group hand-over stays muted; hardware-wins here + would put the backup mic on air at every launch.""" + srcs = {"hs": device("hs", "alsa_input.headset", muted=True)} + seen, moves, writes = sources.hw_mute_changes( + {}, {"alsa_input.headset": False}, srcs) + self.assertEqual(moves, []) + self.assertEqual(writes, [("alsa_input.headset", True)]) + + def test_first_sight_agreement_touches_nothing(self): + srcs = {"hs": device("hs", "alsa_input.headset", muted=True)} + seen, moves, writes = sources.hw_mute_changes( + {}, {"alsa_input.headset": True}, srcs) + self.assertEqual((moves, writes), ([], [])) + self.assertEqual(seen, {"alsa_input.headset": True}) + + def test_an_edge_moves_the_row(self): + srcs = {"hs": device("hs", "alsa_input.headset")} + seen, moves, writes = sources.hw_mute_changes( + {"alsa_input.headset": False}, {"alsa_input.headset": True}, srcs) + self.assertEqual(moves, [("hs", True)]) + self.assertEqual(writes, []) + + def test_disagreement_without_an_edge_is_left_alone(self): + """The row's own writes travel the other way; acting on a mere + disagreement would race a click and flip it back.""" + srcs = {"hs": device("hs", "alsa_input.headset", muted=True)} + seen, moves, writes = sources.hw_mute_changes( + {"alsa_input.headset": False}, {"alsa_input.headset": False}, srcs) + self.assertEqual((moves, writes), ([], [])) + + def test_a_row_click_racing_a_stale_snapshot_is_not_undone(self): + """User mutes the row (row True, hardware written True) but the poll + still carries the pre-click snapshot: no edge, no counter-flip; the + next fresh snapshot is an edge that already agrees with the row.""" + srcs = {"hs": device("hs", "alsa_input.headset", muted=True)} + seen = {"alsa_input.headset": False} + seen, moves, writes = sources.hw_mute_changes( + seen, {"alsa_input.headset": False}, srcs) + self.assertEqual((moves, writes), ([], [])) + seen, moves, writes = sources.hw_mute_changes( + seen, {"alsa_input.headset": True}, srcs) + self.assertEqual((moves, writes), ([], [])) + self.assertEqual(seen, {"alsa_input.headset": True}) + + def test_a_first_sight_write_is_not_undone_by_a_stale_snapshot(self): + """After row-wins wrote unmute, a stale snapshot still reading muted + is not an edge (the observed value was remembered), and the fresh + snapshot that follows agrees with the row.""" + srcs = {"hs": device("hs", "alsa_input.headset")} + seen, moves, writes = sources.hw_mute_changes( + {}, {"alsa_input.headset": True}, srcs) + self.assertEqual(writes, [("alsa_input.headset", False)]) + seen, moves, writes = sources.hw_mute_changes( + seen, {"alsa_input.headset": True}, srcs) + self.assertEqual((moves, writes), ([], [])) + seen, moves, writes = sources.hw_mute_changes( + seen, {"alsa_input.headset": False}, srcs) + self.assertEqual((moves, writes), ([], [])) + + def test_app_sources_and_unknown_nodes_are_ignored(self): + srcs = { + "browser": {"id": "browser", "kind": sources.KIND_APP, + "name": "browser"}, + "ghost": device("ghost", "alsa_input.unplugged"), + "nameless": device("nameless", ""), + } + seen, moves, writes = sources.hw_mute_changes( + {}, {"alsa_input.other": True}, srcs) + self.assertEqual((moves, writes), ([], [])) + self.assertEqual(seen, {}) + + def test_a_vanished_device_is_forgotten_not_remembered(self): + """Its next appearance is a first sight again, so the row's state + reasserts itself over whatever the device came back wearing.""" + srcs = {"hs": device("hs", "alsa_input.headset")} + seen, moves, writes = sources.hw_mute_changes( + {"alsa_input.headset": True}, {}, srcs) + self.assertEqual(seen, {}) + self.assertEqual((moves, writes), ([], [])) + + +class ReadingPactl(unittest.TestCase): + def _run(self, stdout, returncode=0): + result = mock.Mock(stdout=stdout, returncode=returncode) + return mock.patch.object( + mixer_mod.subprocess, "run", return_value=result) + + def test_mutes_come_back_by_name(self): + payload = json.dumps([ + {"name": "alsa_input.headset", "mute": True}, + {"name": "alsa_input.wave", "mute": False}, + {"no_name": "ignored"}, + ]) + with self._run(payload): + self.assertEqual(mixer_mod._pactl_source_mutes(), { + "alsa_input.headset": True, + "alsa_input.wave": False, + }) + + def test_failure_reads_as_nothing_not_as_all_unmuted(self): + with self._run("", returncode=1): + self.assertEqual(mixer_mod._pactl_source_mutes(), {}) + with self._run("not json"): + self.assertEqual(mixer_mod._pactl_source_mutes(), {}) + + def test_setting_goes_through_pactl(self): + with mock.patch.object(mixer_mod, "_run_quiet") as run: + mixer_mod._pactl_set_source_mute("alsa_input.headset", True) + run.assert_called_once_with( + ["pactl", "set-source-mute", "alsa_input.headset", "1"]) + + +class MixerSurface(unittest.TestCase): + def test_set_capture_mute_reaches_the_seam(self): + from .support import bare_mixer + pw = mock.Mock() + m = bare_mixer(_pw=pw) + m.set_capture_mute("alsa_input.headset", True) + pw.set_source_mute.assert_called_once_with( + "alsa_input.headset", True) + + def test_an_empty_node_name_is_not_sent(self): + from .support import bare_mixer + pw = mock.Mock() + m = bare_mixer(_pw=pw) + m.set_capture_mute("", True) + pw.set_source_mute.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index e821536..9569ce5 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -64,6 +64,9 @@ def __init__(self, **kwargs): self.dev = WaveDevice() # the selected device; one of self._devs self._devs = [] # every Wave held open, bus order self._any_hw_muted = False + # {node_name: muted} as of the previous capture poll — the memory + # hw_mute_changes needs to tell an edge from a disagreement. + self._capture_mute_seen = {} self._selector_updating = False self._gain_max = 0x5000 self._updating_ui = False @@ -908,6 +911,10 @@ def _set_row_mute_from_hardware(self, dev, muted): source_id, source = self._source_for_device(dev) if source is None or bool(source.get("muted", False)) == muted: return + self._apply_row_mute(source_id, source, muted) + + def _apply_row_mute(self, source_id, source, muted): + """Move a source row to `muted` without driving the hardware back.""" source["muted"] = muted self.mixer.set_source_level(source_id, source.get("level", 1.0), muted) cell = self.matrix.source(source_id) @@ -1352,6 +1359,7 @@ def _stream_poll_tick(self): if check_devices: self._device_poll_countdown = self._DEVICE_POLL_EVERY self.mixer.request_capture_poll() + self._follow_capture_mutes() for source_id, source in list(self._sources.items()): if sources_module.kind(source) == sources_module.KIND_DEVICE: if check_devices: @@ -1368,6 +1376,36 @@ def _stream_poll_tick(self): self.matrix.set_mix_volume(mix_id, remembered[0]) return True + def _follow_capture_mutes(self): + """Let a device's own mute button reach its matrix row. + + The pactl side of what the USB poll does for open Waves: a headset's + hardware mute flips the source's ALSA mute and nothing downstream can + tell that silence from a quiet room — today's "mic isn't working". + Rows whose device we hold open over USB are excluded; their truth is + the firmware mute the 10 Hz poll already carries. Not through + _sync_hw_mute, same as _set_row_mute_from_hardware: the hardware is + already in the new state. + """ + polled = { + sid: source for sid, source in self._sources.items() + if self._device_for_source(source) is None + } + self._capture_mute_seen, moves, writes = \ + sources_module.hw_mute_changes( + self._capture_mute_seen, self.mixer.capture_mutes(), polled) + for source_id, muted in moves: + logging.info( + "%s: device mute %s outside the mixer; row follows", + self._sources[source_id].get("name", source_id), + "engaged" if muted else "cleared") + self._apply_row_mute(source_id, self._sources[source_id], muted) + for node, muted in writes: + logging.info( + "%s: device mute disagreed with its row at first sight; " + "row wins", node) + self.mixer.set_capture_mute(node, muted) + def _check_capture_stall(self, source_id, source): """Reopen a capture device that enumerated but never started. @@ -1381,6 +1419,11 @@ def _check_capture_stall(self, source_id, source): if not present: self._stall_watch.forget(node_name) return + if source.get("muted") or self.mixer.capture_mutes().get(node_name): + # A muted microphone is silent on purpose; cycling its card + # to cure that silence would only blink the audio. + self._stall_watch.forget(node_name) + return silent_for = self.meter.silent_for(source_id) now = time.monotonic() if not self._stall_watch.should_recover( @@ -2011,10 +2054,19 @@ def _sync_hw_mute(self, source, muted): """ dev = self._device_for_source(source) if dev is None: - if source.get("node_name", "").find("Elgato") >= 0: - logging.warning( - "row mute for %s: no open device matched, hardware " - "mute not mirrored", source.get("name")) + # Not a Wave we hold open — but any capture device still has an + # ALSA-level mute of its own, and leaving it live under a muted + # row (or muted under a live one, the Arctis-headset trap) is + # the same lying mute button. pactl reaches what USB cannot. + node = source.get("node_name", "") + if sources_module.kind(source) == sources_module.KIND_DEVICE \ + and node: + self.mixer.set_capture_mute(node, muted) + if node.find("Elgato") >= 0: + logging.warning( + "row mute for %s: no open device matched, mirrored " + "via pactl only (device LED will not follow)", + source.get("name")) return self._usb_async( lambda: dev.set_mute(bool(muted)), diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 48190ef..96a908b 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -266,6 +266,43 @@ def _pactl_set_sink_mute(sink_name, muted): _run_quiet(["pactl", "set-sink-mute", sink_name, "1" if muted else "0"]) +def _pactl_source_mutes(): + """{source_name: muted} for every source, in one call. + + The device's own mute, not the matrix's: a headset mute button or + another mixer flips this without any stream changing, and a muted + source delivers digital silence that is indistinguishable from a + quiet room everywhere downstream. JSON for the same reason as + _pactl_sink_volumes -- the human listing is localised. + """ + try: + result = subprocess.run( + ["pactl", "--format=json", "list", "sources"], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return {} + if result.returncode != 0: + return {} + try: + sources = json.loads(result.stdout) + except (ValueError, TypeError): + return {} + out = {} + for source in sources if isinstance(sources, list) else (): + if not isinstance(source, dict): + continue + name = source.get("name") + if name: + out[name] = bool(source.get("mute")) + return out + + +def _pactl_set_source_mute(source_name, muted): + _run_quiet(["pactl", "set-source-mute", source_name, + "1" if muted else "0"]) + + def _run_quiet(argv): try: subprocess.run(argv, capture_output=True, text=True, timeout=3) @@ -357,6 +394,12 @@ def set_sink_volume(self, name, volume): def set_sink_mute(self, name, muted): _pactl_set_sink_mute(name, muted) + def source_mutes(self): + return _pactl_source_mutes() + + def set_source_mute(self, name, muted): + _pactl_set_source_mute(name, muted) + def move_stream(self, serial, sink_name): _move_stream(serial, sink_name) @@ -903,6 +946,10 @@ def __init__(self, pw=None): # source can be wired at all. Always *rebound*, never mutated in # place, so a worker-thread read always sees one whole snapshot. self._live_captures = frozenset() + # {node_name: muted} for those same devices -- their own ALSA-level + # mute, refreshed alongside the presence snapshot and likewise + # rebound, never mutated. + self._capture_mutes = {} # Intake sinks we have created, so tearing one down costs no subprocess # when there was never one to tear down. self._intakes = set() @@ -1625,6 +1672,24 @@ def live_captures(self): """ return self._live_captures + def capture_mutes(self): + """{node_name: muted} snapshot of the capture devices' own mutes. + + As stale as the last capture poll (~6 s): the reader wants edges, + not freshness -- a mute flipped by the device's own button between + two polls is still an edge on the next one. + """ + return self._capture_mutes + + def set_capture_mute(self, node_name, muted): + """Set a capture device's own ALSA-level mute, by node name. + + pactl answers in single-digit milliseconds, so this stays on the + caller's thread like the sink-mute writes do. + """ + if node_name: + self._pw.set_source_mute(node_name, bool(muted)) + def _refresh_live_captures(self): """Re-snapshot present capture devices. Returns (added, removed) names. @@ -1640,9 +1705,12 @@ def _refresh_live_captures(self): names = frozenset(source["name"] for source in list_capture_sources()) if not names: return set(), set() + mutes = self._pw.source_mutes() with self._lock: previous = self._live_captures self._live_captures = names + self._capture_mutes = {n: m for n, m in mutes.items() + if n in names} return set(names) - set(previous), set(previous) - set(names) def poll_capture_devices(self): diff --git a/wavexlr/sources.py b/wavexlr/sources.py index 49aaf75..cd7b421 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -198,6 +198,51 @@ def is_protected(source): return bool((source or {}).get("protected")) +def hw_mute_changes(seen, hw_mutes, sources): + """Reconcile device rows with their devices' own ALSA-level mutes. + + `seen` is the {node_name: muted} observed on the previous poll, + `hw_mutes` the current one, `sources` the full source table. Returns + ({node_name: muted} to remember as the new `seen`, + [(source_id, muted), ...] rows to move, + [(node_name, muted), ...] device mutes to write). + + A row moves on an *edge* -- the device's mute changed between polls + and the row disagrees -- never on mere disagreement, because the + row's own mute writes travel the other way and a poll raced against + one would otherwise flip the click back. A device seen for the first + time syncs in the other direction: the row's state is deliberate + mixer state (a group hand-over muted the backup on purpose) while + the device's may be leftovers (a session manager restart restoring + a stale mute -- the muted-headset "mic isn't working" trap), so a + first-sight mismatch writes the row's mute to the device rather + than the device's to the row. From then on the button makes edges + and the row follows. + """ + new_seen = {} + moves = [] + writes = [] + for source_id, source in sources.items(): + if kind(source) != KIND_DEVICE: + continue + node = source.get("node_name") + if not node or node not in hw_mutes: + continue + muted = bool(hw_mutes[node]) + row_muted = bool(source.get("muted", False)) + prev = seen.get(node) + # Always remember what was *observed*, never what was written: + # remembering a write makes the next (possibly stale) snapshot + # read as an edge and undo it. + new_seen[node] = muted + if prev is None: + if row_muted != muted: + writes.append((node, row_muted)) + elif prev != muted and row_muted != muted: + moves.append((source_id, muted)) + return new_seen, moves, writes + + def new_device_source(*, name, node_name, icon_name=DEFAULT_DEVICE_ICON): """Return a fresh capture-device source bound to a PipeWire source node. From 2236f14aa88ded466809bfcc205fa7c333c4802b Mon Sep 17 00:00:00 2001 From: Zedwil Date: Tue, 1 Sep 2026 21:14:15 -0500 Subject: [PATCH 97/99] Teach the watchdogs patience, and make them say what they measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H --- CHANGELOG.md | 16 ++++ README.md | 2 +- docs/troubleshooting.md | 105 +++++++++++++++++++++ tests/test_health.py | 162 +++++++++++++++++++++++++++++--- wavexlr/health.py | 199 ++++++++++++++++++++++++++++++++++------ 5 files changed, 443 insertions(+), 41 deletions(-) create mode 100644 docs/troubleshooting.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ddba2..d187d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ versions are git tags (see [Releases](../../releases)). ## [Unreleased] ### Added +- **Watchdogs that know when to stop**: a remedy budget now refills + only after a sustained quiet stretch (5 min for the glitch watch, + 1 min of movement for the stall watch), not after a single clean + window — one quiet window is a card cycle settling, and refilling on + it turned a persistent fault into an audible pop every two minutes + on real hardware. The glitch watch also ignores muted captures + (muted delivers one inaudible xrun per graph cycle, forever, on + purpose) and baselines fresh on unmute. Logging grew up with it: + each fault announces itself once with the measured numbers, each + remedy carries its attempt count, giving up is said once with when + the watchdog re-arms, and re-arming is logged too. +- **docs/troubleshooting.md**: field notes for the faults every + ordinary check passes — the xrun-diff method, driver-election + robotic mic, small-quantum crackle and the min-quantum floor, + frozen-hw_ptr silent output, and the muted-source + one-xrun-per-cycle signature. - **Device mute buttons and the mixer tell one story**: a capture device's own ALSA-level mute (a headset's hardware mute button, a toggle in another mixer) now syncs with its matrix row in both diff --git a/README.md b/README.md index 3bb23e1..f7366c5 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,7 @@ wavexlr/ wmnames.py — Friendly app names via X11/XWayland (optional) service.py — systemd/runit unit management paths.py — Install-prefix resolution -docs/ — architecture, hardware support, protocol, comparison +docs/ — architecture, hardware support, protocol, troubleshooting tests/ — unit suite (no GTK, no PipeWire, no hardware needed) ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..6c4e943 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,105 @@ +# Troubleshooting audio faults + +Field notes from faults observed on real hardware, with the diagnosis +method that found them. All of them share a property: every ordinary +check passes — nodes report `running`, bytes flow, volumes read fine — +and the audio is still wrong. + +## Measuring xruns (the method) + +PipeWire's per-node xrun counter is exported only by `pw-top`, and it is +**cumulative** over the node's lifetime — a big absolute number is +history, not a fault. Always diff two samples: + +```sh +pw-top -b -n 3 | awk '{print $2,$9,$NF}' > /tmp/a; sleep 10 +pw-top -b -n 3 | awk '{print $2,$9,$NF}' > /tmp/b +# compare ERR per node id between the files +``` + +At least 3 iterations are required: the first two print placeholder +zeros while the profiler warms up. A healthy node's delta is zero or a +handful at stream start; sustained accumulation is audible. + +## Robotic / granular microphone + +**Signature:** the Wave's capture node accumulates xruns continuously +(~23/s observed); recording sounds granular and robotic. Every +byte-level check passes. + +**Cause:** the Wave lost the graph-driver election and follows a clock +its DLL cannot track. All ALSA nodes default `priority.driver = 2100` +and a tie falls to the lowest object id — observed handing the graph +clock to a wireless headset dongle whose jittery delivery the Wave +resynced against forever. + +**Fix:** the shipped WirePlumber conf pins `priority.driver = 2500` on +Wave nodes so their wired isochronous clock drives. Verify with +`pw-dump`: other nodes' `driver-id` should point at the Wave capture +node. + +## Crackles / pops on playback + +**Signature:** every follower device xruns; the sinks pop a few times a +second. Worst while a WebRTC app (Discord) runs. + +**Cause:** apps request small quanta (WebRTC asks for 360) and +full-speed USB followers miss deadlines below ~512 once they no longer +drive the clock. Measured: a headset dongle capture at 188 xruns/s and +~2 pops/s on its sink at quantum 360; zero xruns on every live node at +1024. + +**Fix (user machine, not shipped):** floor the quantum — + +``` +# ~/.config/pipewire/pipewire.conf.d/90-min-quantum.conf +context.properties = { + default.clock.min-quantum = 1024 +} +``` + +1024 @ 48 kHz is 21.3 ms — fine for voice chat, required for clean +multi-device mixing. Apply live with +`pw-metadata -n settings 0 clock.min-quantum 1024`. For a stubborn +batch device, `api.alsa.headroom = 1024` in a WirePlumber rule adds +device-side slack. + +## Silent output while everything reports running + +**Signature:** the sink node runs, the graph delivers samples, volume +and mute read fine — and the hardware plays silence. Observed after a +WirePlumber restart recreated device nodes. + +**Cause:** the ALSA PCM behind the sink stopped consuming; only the +kernel shows it, in `/proc/asound/cardN/pcmNp/subN/status` — `hw_ptr` +frozen (or `state: XRUN`) while the stream claims to run. + +**Fix:** close and reopen the PCM: `pactl suspend-sink 1`, then +`0`. The daemon's stall watchdog does this automatically, rate-limited. + +## A source that xruns once per graph cycle, forever + +**Signature:** one capture node's xrun delta exactly matches the graph +cycle rate (23/s at quantum 2048, 47/s at 1024) and never varies. + +**Cause:** the source is muted at the ALSA level (`pactl list sources` +shows `Mute: yes`, or the card's `Capture Switch` is off) — a headset's +own mute button, or a stale state restore. It delivers digital silence; +the xruns are inaudible bookkeeping. `Status: Stop` in the card's +`/proc/asound` stream file with the node running is the same family: +reopen with `pactl suspend-source 1` then `0`. + +**Note:** the daemon's glitch watchdog ignores muted captures for this +reason, and the mixer syncs device-level mutes with their matrix rows, +so a mute engaged outside OpenWave shows in the window instead of +reading as a dead microphone. + +## Watchdog behavior + +Both daemon watchdogs (`wavexlr/health.py`) act at most twice per +incident, 60 s apart, then leave the device alone to be noticed — a +remedy that did not stick must not become a loop of audible pops. The +budget re-arms only after a sustained quiet stretch (5 min for the +glitch watch, 1 min of movement for the stall watch). Every remedy and +give-up is logged under `wavexlr.health` with the measured numbers; +`journalctl --user -u openwave.service` shows them. diff --git a/tests/test_health.py b/tests/test_health.py index a2b0396..09726b6 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -7,6 +7,7 @@ """ import unittest +from unittest import mock from wavexlr import health @@ -105,14 +106,64 @@ def test_cooldown_blocks_a_rapid_second_attempt(self): self.assertFalse(self.w.should_recover(DOCK, now=30.0)) self.assertTrue(self.w.should_recover(DOCK, now=90.0)) - def test_a_clean_window_refills_the_budget(self): - """Recovery is per incident, not per process lifetime.""" + def test_one_clean_window_does_not_refill_the_budget(self): + """The loop observed on hardware: a card cycle buys a quiet + window while the capture reopens, the refill re-arms, and a + persistent fault becomes a pop every two minutes. One quiet + window is the incident still going, not recovery.""" + w = health.GlitchWatch(threshold=50, confirm=2, + cooldown_seconds=60, max_attempts=2, + clean_refill=3) + for i, c in enumerate([0, 230, 460]): + w.observe(DOCK, c, i * 10.0) + w.record_attempt(DOCK, 20.0) + w.record_attempt(DOCK, 90.0) + w.observe(DOCK, 461, 100.0) # one quiet window + for i, c in enumerate([700, 940, 1180]): + w.observe(DOCK, c, 200.0 + i * 10.0) + self.assertFalse(w.should_recover(DOCK, now=300.0)) + + def test_sustained_quiet_refills_the_budget(self): + """Recovery is per incident, not per process lifetime — but the + incident has to actually end first.""" + w = health.GlitchWatch(threshold=50, confirm=2, + cooldown_seconds=60, max_attempts=2, + clean_refill=3) + for i, c in enumerate([0, 230, 460]): + w.observe(DOCK, c, i * 10.0) + w.record_attempt(DOCK, 20.0) + w.record_attempt(DOCK, 90.0) + for i, c in enumerate([461, 462, 463]): # sustained quiet + w.observe(DOCK, c, 100.0 + i * 10.0) + for i, c in enumerate([700, 940, 1180]): # a fresh incident + w.observe(DOCK, c, 300.0 + i * 10.0) + self.assertTrue(w.should_recover(DOCK, now=400.0)) + + def test_a_post_cycle_counter_reset_is_not_a_clean_window(self): + """The reset after a card cycle proves nothing about the fault; + counting it toward refill would shave a window off the leash.""" + w = health.GlitchWatch(threshold=50, confirm=2, + cooldown_seconds=60, max_attempts=2, + clean_refill=2) + for i, c in enumerate([0, 230, 460]): + w.observe(DOCK, c, i * 10.0) + w.record_attempt(DOCK, 20.0) + w.record_attempt(DOCK, 90.0) + w.observe(DOCK, 5, 100.0) # recreated: baseline, not clean + w.observe(DOCK, 6, 110.0) # one genuinely clean window + for i, c in enumerate([200, 440, 680]): + w.observe(DOCK, c, 200.0 + i * 10.0) + self.assertFalse(w.should_recover(DOCK, now=300.0)) + + def test_confirmation_fires_exactly_once_per_incident(self): + """The window that crosses `confirm` is the one to log; every + later glitchy window would repeat the same warning every 10 s + for the life of the fault.""" self.feed([0, 230, 460]) - self.w.record_attempt(DOCK, 20.0) - self.w.record_attempt(DOCK, 90.0) - self.feed([461, 462], start=100.0) # clean: recovered - self.feed([700, 940], start=200.0) # a fresh incident - self.assertTrue(self.w.should_recover(DOCK, now=300.0)) + self.assertTrue(self.w.just_confirmed(DOCK)) + self.feed([690], start=100.0) + self.assertFalse(self.w.just_confirmed(DOCK)) + self.assertTrue(self.w.glitching(DOCK)) def test_forget_starts_clean(self): self.feed([0, 230, 460]) @@ -180,15 +231,98 @@ def test_attempts_are_capped_and_cooled_down(self): self.w.observe(SINK, True, 500, "RUNNING", 160.0) self.assertFalse(self.w.should_recover(SINK, now=300.0)) # spent - def test_movement_refills_the_budget(self): + def test_sustained_movement_refills_the_budget(self): + w = health.SinkStallWatch(cooldown_seconds=60, max_attempts=2, + clean_refill=2) + w.observe(SINK, True, 1000, "RUNNING", 0.0) + w.observe(SINK, True, 1000, "RUNNING", 10.0) + w.record_attempt(SINK, 10.0) + w.record_attempt(SINK, 80.0) + w.observe(SINK, True, 2000, "RUNNING", 90.0) # moving… + w.observe(SINK, True, 50000, "RUNNING", 100.0) # …recovered + w.observe(SINK, True, 90000, "RUNNING", 110.0) + w.observe(SINK, True, 90000, "RUNNING", 120.0) # new stall + self.assertTrue(w.should_recover(SINK, now=200.0)) + + def test_one_moving_window_does_not_refill(self): + """A recycle resets the pointer, and the window after can move + once without the PCM being healthy.""" + w = health.SinkStallWatch(cooldown_seconds=60, max_attempts=2, + clean_refill=2) + w.observe(SINK, True, 1000, "RUNNING", 0.0) + w.observe(SINK, True, 1000, "RUNNING", 10.0) + w.record_attempt(SINK, 10.0) + w.record_attempt(SINK, 80.0) + w.observe(SINK, True, 2000, "RUNNING", 90.0) # moved once + w.observe(SINK, True, 2000, "RUNNING", 100.0) # stalled again + w.observe(SINK, True, 2000, "RUNNING", 110.0) + self.assertFalse(w.should_recover(SINK, now=300.0)) + + def test_a_stall_announces_itself_exactly_once(self): self.w.observe(SINK, True, 1000, "RUNNING", 0.0) self.w.observe(SINK, True, 1000, "RUNNING", 10.0) - self.w.record_attempt(SINK, 10.0) - self.w.record_attempt(SINK, 80.0) - self.w.observe(SINK, True, 2000, "RUNNING", 90.0) # recovered - self.w.observe(SINK, True, 50000, "RUNNING", 100.0) - self.w.observe(SINK, True, 50000, "RUNNING", 110.0) # new stall - self.assertTrue(self.w.should_recover(SINK, now=200.0)) + self.assertTrue(self.w.just_stalled(SINK)) + self.w.observe(SINK, True, 1000, "RUNNING", 20.0) + self.assertFalse(self.w.just_stalled(SINK)) + + +class MonitorBehavior(unittest.TestCase): + """check_once with every seam faked: no pw-top, pactl or card.""" + + def setUp(self): + self.m = health.HealthMonitor() + self.xruns = {DOCK: 0} + self.mutes = {} + self.cycled = [] + patches = [ + mock.patch.object(health, "snapshot_graph", + lambda: ([DOCK], {})), + mock.patch.object(health, "sample_xruns", + lambda: dict(self.xruns)), + mock.patch.object(health, "sample_source_mutes", + lambda: dict(self.mutes)), + mock.patch.object(health.recovery, "card_name_for", + lambda name: "card"), + mock.patch.object(health.recovery, "cycle_card", + lambda card: self.cycled.append(card) or True), + ] + for p in patches: + p.start() + self.addCleanup(p.stop) + + def tick(self, xruns, t): + self.xruns[DOCK] = xruns + self.m.check_once(now=t) + + def test_a_muted_capture_is_never_judged(self): + """A muted source xruns once per graph cycle, forever, on + purpose; cycling its card would blink everyone else's audio.""" + self.mutes[DOCK] = True + for i, c in enumerate([0, 500, 1000, 1500, 2000]): + self.tick(c, i * 10.0) + self.assertEqual(self.cycled, []) + + def test_the_fault_gets_two_cycles_then_the_device_is_left_alone(self): + with self.assertLogs("wavexlr.health", level="WARNING") as logs: + t = 0.0 + for _ in range(30): # 5 min of sustained fault + self.tick(self.xruns[DOCK] + 500, t) + t += 10.0 + self.assertEqual(len(self.cycled), 2) + gave_up = [r for r in logs.output if "leaving the device" in r] + self.assertEqual(len(gave_up), 1) + + def test_unmuting_starts_from_a_fresh_baseline(self): + """The cumulative counter kept climbing while muted; comparing + against the pre-mute value would misread the whole muted + stretch as one giant glitchy window.""" + self.tick(100, 0.0) + self.mutes[DOCK] = True + self.tick(5000, 10.0) + self.mutes[DOCK] = False + self.tick(5010, 20.0) # baseline only + self.tick(5020, 30.0) + self.assertEqual(self.cycled, []) if __name__ == "__main__": diff --git a/wavexlr/health.py b/wavexlr/health.py index 241a4f8..424a47f 100644 --- a/wavexlr/health.py +++ b/wavexlr/health.py @@ -67,6 +67,18 @@ COOLDOWN_SECONDS = 60.0 MAX_ATTEMPTS = 2 +# Consecutive clean windows before a spent remedy budget refills. One +# clean window is not recovery: a card cycle buys a quiet window or two +# while the capture reopens, and refilling on it turned a persistent +# fault into a cycle-every-two-minutes loop on real hardware (observed +# 2026-09-01: three cycles in four minutes, each an audible pop, each +# leaking a duplicate sink node in WirePlumber). Five quiet minutes is +# recovery; anything shorter is the same incident still going. +GLITCH_CLEAN_REFILL_CHECKS = 30 +# A sink pointer moving again is a stronger signal than a quiet xrun +# counter, so the stall watch refills after one quiet minute. +STALL_CLEAN_REFILL_CHECKS = 6 + # --- sampling seams (each one shell or /proc; patched out in tests) --- @@ -122,6 +134,37 @@ def read_playback_status(card, device, subdevice): state.group(1) if state else None) +def sample_source_mutes(): + """{source_name: muted} for every source, from pactl. + + A muted capture delivers digital silence and, observed on hardware, + one xrun per graph cycle forever — which at small quanta crosses the + glitch threshold. That is silence on purpose, not a fault, and + cycling the card of a deliberately muted microphone would only blink + everyone else's audio. JSON because pactl's human listing is + localised. + """ + import json + try: + r = subprocess.run( + ["pactl", "--format=json", "list", "sources"], + capture_output=True, text=True, timeout=5, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return {} + if r.returncode != 0: + return {} + try: + sources = json.loads(r.stdout) + except (ValueError, TypeError): + return {} + out = {} + for source in sources if isinstance(sources, list) else (): + if isinstance(source, dict) and source.get("name"): + out[source["name"]] = bool(source.get("mute")) + return out + + def recycle_sink(sink_name): """Close and reopen a sink's PCM by suspending and resuming it.""" for flag in ("1", "0"): @@ -197,18 +240,22 @@ class GlitchWatch: def __init__(self, threshold=GLITCH_XRUNS_PER_CHECK, confirm=GLITCH_CONFIRM_CHECKS, cooldown_seconds=COOLDOWN_SECONDS, - max_attempts=MAX_ATTEMPTS): + max_attempts=MAX_ATTEMPTS, + clean_refill=GLITCH_CLEAN_REFILL_CHECKS): self.threshold = threshold self.confirm = confirm self.cooldown_seconds = cooldown_seconds self.max_attempts = max_attempts + self.clean_refill = clean_refill self._prev = {} # node_name -> last cumulative count self._streak = {} # node_name -> consecutive bad windows + self._clean = {} # node_name -> consecutive clean windows + self._delta = {} # node_name -> xruns in the last window self._attempts = {} # node_name -> remedies spent self._last_attempt = {} # node_name -> monotonic time def forget(self, node_name): - for d in (self._prev, self._streak, + for d in (self._prev, self._streak, self._clean, self._delta, self._attempts, self._last_attempt): d.pop(node_name, None) @@ -218,21 +265,42 @@ def observe(self, node_name, xruns, now): self._prev[node_name] = xruns if prev is None or xruns < prev: # First sight, or the node was recreated: baseline only. + # Deliberately not a clean window — the reset after a card + # cycle proves nothing about the fault. self._streak[node_name] = 0 + self._delta[node_name] = 0 return False + self._delta[node_name] = xruns - prev if xruns - prev >= self.threshold: self._streak[node_name] = self._streak.get(node_name, 0) + 1 + self._clean[node_name] = 0 return True - # A clean window after a remedy is the recovery signal: the - # budget refills for the next incident rather than staying - # spent forever. + # One clean window is not recovery — a card cycle buys a quiet + # window while the capture reopens, and refilling on it turns a + # persistent fault into an endless cycle-pop loop. The budget + # refills only after a sustained stretch of quiet. self._streak[node_name] = 0 - self._attempts.pop(node_name, None) + clean = self._clean.get(node_name, 0) + 1 + self._clean[node_name] = clean + if clean >= self.clean_refill: + self._attempts.pop(node_name, None) return False def glitching(self, node_name): return self._streak.get(node_name, 0) >= self.confirm + def just_confirmed(self, node_name): + """True exactly once per incident, when it crosses `confirm`.""" + return self._streak.get(node_name, 0) == self.confirm + + def last_delta(self, node_name): + """xruns accumulated in the last observed window, for logging.""" + return self._delta.get(node_name, 0) + + def spent(self, node_name): + """Remedy attempts spent on the current incident.""" + return self._attempts.get(node_name, 0) + def should_recover(self, node_name, now): if not self.glitching(node_name): return False @@ -259,21 +327,26 @@ class SinkStallWatch: """ def __init__(self, cooldown_seconds=COOLDOWN_SECONDS, - max_attempts=MAX_ATTEMPTS): + max_attempts=MAX_ATTEMPTS, + clean_refill=STALL_CLEAN_REFILL_CHECKS): self.cooldown_seconds = cooldown_seconds self.max_attempts = max_attempts + self.clean_refill = clean_refill self._prev_ptr = {} # sink_name -> last hw_ptr self._stalled = {} # sink_name -> bool + self._was_stalled = {} # sink_name -> stalled on previous window + self._clean = {} # sink_name -> consecutive moving windows self._attempts = {} # sink_name -> remedies spent self._last_attempt = {} # sink_name -> monotonic time def forget(self, sink_name): - for d in (self._prev_ptr, self._stalled, - self._attempts, self._last_attempt): + for d in (self._prev_ptr, self._stalled, self._was_stalled, + self._clean, self._attempts, self._last_attempt): d.pop(sink_name, None) def observe(self, sink_name, running, hw_ptr, alsa_state, now): """Account one window; True when the sink is stalled.""" + self._was_stalled[sink_name] = self._stalled.get(sink_name, False) prev = self._prev_ptr.get(sink_name) self._prev_ptr[sink_name] = hw_ptr if not running or hw_ptr is None: @@ -284,16 +357,35 @@ def observe(self, sink_name, running, hw_ptr, alsa_state, now): return False if alsa_state == "XRUN": self._stalled[sink_name] = True + self._clean[sink_name] = 0 return True if prev is None: self._stalled[sink_name] = False return False stalled = hw_ptr == prev self._stalled[sink_name] = stalled - if not stalled: - self._attempts.pop(sink_name, None) + if stalled: + self._clean[sink_name] = 0 + else: + # Same reasoning as the glitch watch, shorter leash: a + # recycle resets the pointer and the next window can move + # once without the PCM being healthy, so refill only after + # a sustained stretch of movement. + clean = self._clean.get(sink_name, 0) + 1 + self._clean[sink_name] = clean + if clean >= self.clean_refill: + self._attempts.pop(sink_name, None) return stalled + def just_stalled(self, sink_name): + """True on the window a stall begins, for logging it once.""" + return (self._stalled.get(sink_name, False) + and not self._was_stalled.get(sink_name, False)) + + def spent(self, sink_name): + """Remedy attempts spent on the current incident.""" + return self._attempts.get(sink_name, 0) + def should_recover(self, sink_name, now): if not self._stalled.get(sink_name): return False @@ -330,6 +422,10 @@ def __init__(self): self.stall = SinkStallWatch() self._known_captures = set() self._known_sinks = set() + # Names whose remedy budget ran out while the fault persisted, + # so "leaving it alone" is said once rather than every window. + self._glitch_gave_up = set() + self._stall_gave_up = set() def start(self): if self._running: @@ -353,49 +449,100 @@ def check_once(self, now=None): # not inherit a spent remedy budget or a stale counter baseline. for gone in self._known_captures - set(captures): self.glitch.forget(gone) + self._glitch_gave_up.discard(gone) for gone in self._known_sinks - set(sinks): self.stall.forget(gone) + self._stall_gave_up.discard(gone) self._known_captures = set(captures) self._known_sinks = set(sinks) if captures: counts = sample_xruns() + mutes = sample_source_mutes() for name in captures: if name not in counts: continue + if mutes.get(name): + # Muted is silent on purpose — and, observed on + # hardware, xruns once per graph cycle while it + # lasts. Forget rather than skip so unmuting starts + # from a fresh baseline instead of a stale one. + self.glitch.forget(name) + self._glitch_gave_up.discard(name) + continue if not self.glitch.observe(name, counts[name], now): + if (name in self._glitch_gave_up + and self.glitch.spent(name) == 0): + self._glitch_gave_up.discard(name) + log.info( + "%s has been quiet long enough — the glitch " + "watchdog is re-armed", name) continue - log.warning( - "%s accumulated xruns this window — the capture is " - "glitching (robotic audio) while every byte-level " - "check passes", name) + if self.glitch.just_confirmed(name): + log.warning( + "%s is accumulating xruns (%d in the last %.0fs " + "window, threshold %d) — the capture is glitching " + "(robotic audio) while every byte-level check " + "passes", name, self.glitch.last_delta(name), + CHECK_INTERVAL, self.glitch.threshold) if self.glitch.should_recover(name, now): self.glitch.record_attempt(name, now) card = recovery.card_name_for(name) if card and recovery.cycle_card(card): log.warning( - "cycled %s to reopen the glitching capture; " - "if this recurs, another node is winning the " - "graph-driver election over the Wave — check " - "priority.driver in the wireplumber conf", - card) + "cycled %s to reopen the glitching capture " + "(attempt %d/%d); if this recurs, another " + "node may be winning the graph-driver " + "election over the Wave (priority.driver in " + "the wireplumber conf) or the graph quantum " + "may be too small for a follower " + "(clock.min-quantum)", card, + self.glitch.spent(name), + self.glitch.max_attempts) + elif (self.glitch.spent(name) >= self.glitch.max_attempts + and name not in self._glitch_gave_up): + self._glitch_gave_up.add(name) + log.warning( + "%s is still glitching after %d card cycles — " + "leaving the device alone to be noticed; the " + "watchdog re-arms after %.0f quiet minutes", + name, self.glitch.max_attempts, + self.glitch.clean_refill * CHECK_INTERVAL / 60) for name, sink in sinks.items(): ptr, state = read_playback_status( sink["card"], sink["device"], sink["subdevice"]) if not self.stall.observe(name, sink["running"], ptr, state, now): + if (name in self._stall_gave_up + and self.stall.spent(name) == 0): + self._stall_gave_up.discard(name) + log.info( + "%s is consuming again — the stall watchdog is " + "re-armed", name) continue - log.warning( - "%s claims to be running but its hardware pointer is " - "not moving — the graph is delivering audio the device " - "is not playing", name) + if self.stall.just_stalled(name): + log.warning( + "%s claims to be running but its hardware pointer " + "is not moving (hw_ptr=%s, state=%s) — the graph is " + "delivering audio the device is not playing", + name, ptr, state) if self.stall.should_recover(name, now): self.stall.record_attempt(name, now) if recycle_sink(name): log.warning( - "suspended and resumed %s to reopen its PCM", - name) + "suspended and resumed %s to reopen its PCM " + "(attempt %d/%d)", name, self.stall.spent(name), + self.stall.max_attempts) + elif (self.stall.spent(name) >= self.stall.max_attempts + and name not in self._stall_gave_up): + self._stall_gave_up.add(name) + log.warning( + "%s is still stalled after %d suspend/resume " + "attempts — leaving it alone to be noticed; the " + "watchdog re-arms after %.0f minutes of movement", + name, self.stall.max_attempts, + self.stall.clean_refill * CHECK_INTERVAL / 60) def _run(self): while self._running: From 1fdc2bdacafc5c1253c442f419f140a6cea77686 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Tue, 1 Sep 2026 21:28:12 -0500 Subject: [PATCH 98/99] Cut 1.2.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SLLnrFa7QM3hGw3btnZu6H --- CHANGELOG.md | 2 ++ com.github.openwave.metainfo.xml | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d187d8d..fd10a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ versions are git tags (see [Releases](../../releases)). ## [Unreleased] +## [1.2.0] — 2026-09-01 + ### Added - **Watchdogs that know when to stop**: a remedy budget now refills only after a sustained quiet stretch (5 min for the glitch watch, diff --git a/com.github.openwave.metainfo.xml b/com.github.openwave.metainfo.xml index 26ae62e..e232383 100644 --- a/com.github.openwave.metainfo.xml +++ b/com.github.openwave.metainfo.xml @@ -48,6 +48,7 @@ + From 24940b45accb11feddf1db8af9454d6b23b6e839 Mon Sep 17 00:00:00 2001 From: Zedwil Date: Thu, 3 Sep 2026 11:20:42 -0500 Subject: [PATCH 99/99] Keep the main loop free, and let a calibration be stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CMZt7J2961pMWSg2e1qjGp --- tests/test_calibrate.py | 105 +++++++++++++++++++++ wavexlr/app.py | 197 ++++++++++++++++++++++++++++++++-------- wavexlr/calibrate.py | 133 ++++++++++++++++----------- wavexlr/meter.py | 14 ++- wavexlr/mixer.py | 31 +++++++ wavexlr/mixmatrix.py | 45 +++++++-- 6 files changed, 426 insertions(+), 99 deletions(-) diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index 9e8e8f8..4ac99fa 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -1,6 +1,10 @@ """Calibration analysis: measurements in, sane thresholds out.""" +import os +import threading +import time import unittest +from unittest import mock from wavexlr import calibrate @@ -112,5 +116,106 @@ def test_one_sided_stereo_reads_unbalanced(self): self.assertLess(m["balance"], 0.05) +class FakeProc: + """A pw-cat that writes what it is told to, down a real pipe. + + A real pipe rather than a stub file object because the capture loop + selects on the descriptor — a mock that merely returns bytes would not + exercise the thing being tested. + """ + + def __init__(self, payload=b"", chunk=8192): + self._read_fd, self._write_fd = os.pipe() + self.stdout = os.fdopen(self._read_fd, "rb", buffering=0) + self.terminated = False + self.killed = False + self.reaped = False + self._writer = threading.Thread( + target=self._write, args=(payload, chunk), daemon=True) + self._writer.start() + + def _write(self, payload, chunk): + try: + for i in range(0, len(payload), chunk): + os.write(self._write_fd, payload[i:i + chunk]) + except OSError: + pass + # Deliberately left open: a stalled pw-cat neither delivers nor + # exits, which is exactly the case the deadline exists for. + + def terminate(self): + self.terminated = True + try: + os.close(self._write_fd) + except OSError: + pass + + def kill(self): + self.killed = True + + def wait(self, timeout=None): + self.reaped = True + return 0 + + +class Capture(unittest.TestCase): + def setUp(self): + self.procs = [] + + def _popen(self, payload=b""): + def factory(*_a, **_kw): + proc = FakeProc(payload) + self.procs.append(proc) + return proc + return factory + + def test_a_stalled_node_ends_at_the_deadline(self): + """No audio, no EOF: the read must give up rather than block forever.""" + with mock.patch("subprocess.Popen", self._popen(b"")), \ + mock.patch.object(calibrate, "GRACE_SECONDS", 0.2): + started = time.monotonic() + with self.assertRaisesRegex(calibrate.CalibrationError, "stalled"): + calibrate.capture_raw("node", 1, channels=1) + self.assertLess(time.monotonic() - started, 5, + "a stalled capture must not hang the worker") + self.assertTrue(self.procs[0].terminated) + self.assertTrue(self.procs[0].reaped, "an unreaped pw-cat is a zombie") + + def test_cancel_stops_the_capture_in_flight(self): + """Cancel is polled during the read, not only between captures.""" + cancelled = threading.Event() + cancelled.set() + with mock.patch("subprocess.Popen", self._popen(b"")): + with self.assertRaises(calibrate.CalibrationCancelled): + calibrate.capture_raw("node", 5, cancel=cancelled.is_set) + self.assertTrue(self.procs[0].terminated, + "cancelling must stop the child, not abandon it") + + def test_a_full_capture_returns_its_seconds_of_audio(self): + rate, frame, seconds = calibrate.RATE, 4, 1 + payload = b"\x10\x27\x10\x27" * (rate * (seconds + 1)) + with mock.patch("subprocess.Popen", self._popen(payload)): + raw = calibrate.capture_raw("node", seconds) + # The half-second connection transient is dropped, the rest kept. + self.assertGreaterEqual(len(raw), rate * frame * seconds // 2) + self.assertEqual(len(raw) % frame, 0) + + def test_a_missing_pw_cat_is_a_calibration_error(self): + with mock.patch("subprocess.Popen", side_effect=OSError("no pw-cat")): + with self.assertRaisesRegex(calibrate.CalibrationError, "record"): + calibrate.capture_raw("node", 1) + + +class EmptyMeasurements(unittest.TestCase): + def test_percentile_of_nothing_explains_itself(self): + """Not IndexError, and not the largest value standing in silently.""" + with self.assertRaises(calibrate.CalibrationError): + calibrate._percentile([], 50) + + def test_analyze_with_no_speech_windows(self): + with self.assertRaises(calibrate.CalibrationError): + calibrate.analyze(windows(-62), []) + + if __name__ == "__main__": unittest.main() diff --git a/wavexlr/app.py b/wavexlr/app.py index 9569ce5..c1307c7 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -86,6 +86,11 @@ def __init__(self, **kwargs): self._fx_debounce_ids = {} self._remote_levels = {} self._push_id = None + # Live _usb_async threads, so shutdown can wait for them. Set before + # anything in construction can start one. + self._workers = set() + # Source ids with a calibration in flight; one per row at a time. + self._calibrating = set() # One-shot re-read of the routing after a mix output change settles. self._output_refresh_id = None self._sources = sources_module.load_seeded() @@ -672,7 +677,25 @@ def _worker(): except Exception as e: if on_error: GLib.idle_add(on_error, e) - threading.Thread(target=_worker, daemon=True).start() + finally: + self._workers.discard(threading.current_thread()) + thread = threading.Thread(target=_worker, daemon=True) + # Tracked so shutdown can wait for them. These threads hold the + # libusb handle and run subprocesses; closing the device out from + # under one, then finalizing the interpreter while it is still + # inside C code, is a segfault on the way out — which reads as + # "it crashed when I closed it". + self._workers.add(thread) + thread.start() + + def _join_workers(self, timeout=2.0): + """Give in-flight background work a bounded chance to finish.""" + deadline = time.monotonic() + timeout + for thread in list(self._workers): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + thread.join(timeout=remaining) def _try_connect(self): # One connect at a time: the device watch, the poll-error path and @@ -839,6 +862,27 @@ def _stop_polling(self): GLib.source_remove(self._poll_id) self._poll_id = None + def _stop_timers(self): + """Disarm every timer this window owns. + + Only the 10 Hz USB poll was ever stopped, so on the way out the + 2 s stream tick, the device watch, the reconnect tick and every + pending debounce stayed armed — free to fire against a half-torn-down + window, and to re-enter work that shutdown had already finished. + """ + self._stop_polling() + for attr in ("_stream_poll_id", "_device_watch_id", "_reconnect_id", + "_output_refresh_id", "_push_id"): + source_id = getattr(self, attr, None) + if source_id: + GLib.source_remove(source_id) + setattr(self, attr, None) + for ids in (self._cell_debounce_ids, self._fx_debounce_ids): + for pending in list(ids.values()): + GLib.source_remove(pending) + ids.clear() + self._throttle.cancel_all() + def _poll_tick(self): """Called every 100ms — read every device's state in background. @@ -1101,16 +1145,27 @@ def _output_entries(self, mix_id, sinks, default_sink): return entries, current, summary, monitored def _refresh_outputs(self): - """Push the live sink list into every mix header's output menu.""" - sinks = list_output_sinks() - default_sink = default_sink_name() - for mix_id in self._mixes: - entries, current, summary, monitored = self._output_entries( - mix_id, sinks, default_sink, - ) - self.matrix.set_mix_outputs( - mix_id, entries, current, summary, monitored, - ) + """Push the live sink list into every mix header's output menu. + + The enumeration is a pw-dump plus a pactl; both run on a worker and + only the menu-filling half runs here, because this is reached from a + 400 ms timer after every output change as well as from window + construction. + """ + def _query(): + return list_output_sinks(), default_sink_name() + + def _apply(result): + sinks, default_sink = result + for mix_id in list(self._mixes): + entries, current, summary, monitored = self._output_entries( + mix_id, sinks, default_sink, + ) + self.matrix.set_mix_outputs( + mix_id, entries, current, summary, monitored, + ) + + self._usb_async(_query, on_done=_apply) def _on_mix_volume_changed(self, _matrix, mix_id, value): """A header's master slider moved: throttled like every live slider, @@ -1346,14 +1401,20 @@ def _start_stream_poll(self): _DEVICE_POLL_EVERY = 3 def _stream_poll_tick(self): - self.mixer.poll_streams() - if not self.mixer.volumes_restored: - # _do_start restores once, and the mix sinks may not have existed - # yet when it did -- first run creates them, and a PipeWire - # restart recreates them. Retrying here is what reopens the gate; - # without it the masters stay at whatever the daemon made them. - self.mixer.restore_mix_volumes() - self.mixer.observe_mix_volumes() + # Everything that shells out goes to the mixer's worker; this tick + # only reads the snapshots those tasks leave behind. A pw-dump or a + # pactl on the GTK thread every 2 seconds is a main loop that + # regularly stops reading its Wayland connection, and a compositor + # that re-tiles windows as they move produces enough configure + # traffic during a drag to fill the socket and have the client cut + # loose -- the window disappearing with no traceback to show for it. + self.mixer.request_stream_poll() + # restore-then-observe, in that order and gated the same way: + # _do_start restores once, and the mix sinks may not have existed + # yet when it did -- first run creates them, and a PipeWire restart + # recreates them. Retrying reopens the gate; without it the masters + # stay at whatever the daemon made them. + self.mixer.request_volume_sync() self._device_poll_countdown -= 1 check_devices = self._device_poll_countdown <= 0 if check_devices: @@ -1436,10 +1497,23 @@ def _check_capture_stall(self, source_id, source): logging.warning( "%s has produced no audio for %.0fs; reopening %s", source.get("name", source_id), silent_for, card) - if recovery_module.cycle_card(card): + + def _cycled(ok, sid=source_id): + if not ok: + return # The node is destroyed and recreated by the cycle, so the meter # is pointing at something that no longer exists. - self._refresh_device_meter(source_id, source) + current = self._sources.get(sid) + if current is not None: + self._refresh_device_meter(sid, current) + + # Off the GTK thread: cycle_card is three pactl calls at a 5 second + # timeout each, and this runs from a 2 second tick. Blocking the + # main loop for that long stalls the Wayland connection along with + # the UI, which is fatal on a compositor that expects prompt replies + # to the configure events a window move generates. + self._usb_async(lambda: recovery_module.cycle_card(card), + on_done=_cycled) def _start_meters(self): """Meter every source that has something to meter.""" @@ -1636,7 +1710,10 @@ def _install_source(self, source): for mix_id in self._mixes: self._wire_cell(source["id"], mix_id) self.mixer.set_sources(self._sources) - self.mixer.poll_streams() + # On the worker: this runs once per discovered device at startup, and + # a pw-dump apiece on the GTK thread is exactly the stall that costs + # the window its Wayland connection while it is being moved. + self.mixer.request_stream_poll() self._refresh_source_meter(source["id"]) self._refresh_mix_emptiness() @@ -1664,9 +1741,17 @@ def _autodiscover_elgato_inputs(self): Offered once, not enforced: a node this has already proposed is recorded, so a row the user deletes stays deleted instead of coming back on the next launch. + + The enumeration is a pw-dump, so it happens on a worker and the rows + are added when it lands -- this is reached from window construction + and from every USB reconnect, and it used to spend two pw-dumps of + GTK-thread time on both. """ + self._usb_async(_list_captures, on_done=self._add_discovered_inputs) + + def _add_discovered_inputs(self, devices): elgato_nodes = { - d["name"] for d in _list_captures() + d["name"] for d in devices if d.get("vendor_id") == ELGATO_VID and d.get("name") } # A row added before this flag existed, or one the user added by hand @@ -1684,7 +1769,7 @@ def _autodiscover_elgato_inputs(self): bound = self._bound_capture_nodes() added = [] - for dev in _list_captures(): + for dev in devices: node = dev.get("name") if dev.get("vendor_id") != ELGATO_VID: continue @@ -1804,6 +1889,11 @@ def _on_fx_autotune(self, cell, source_id): the previous calibration. """ from . import calibrate + # One at a time. Two runs mean two pw-cat pairs on the same node, + # two stacked modals, and whichever finishes last silently winning + # the store — including over the other's freshly written settings. + if source_id in self._calibrating: + return source = self._sources.get(source_id) node = (source or {}).get("node_name") if not node or not self.mixer.capture_device_present(node): @@ -1828,39 +1918,42 @@ def _on_fx_autotune(self, cell, source_id): def _go(d, result): if d.choose_finish(result) == "start": - self._calibrate_run(cell, source_id, node) + self._calibrate_run(source_id, node) intro.choose(self, None, _go) - def _calibrate_run(self, cell, source_id, node): + def _calibrate_run(self, source_id, node): from . import calibrate - state = {"cancelled": False} + self._calibrating.add(source_id) + cancelled = threading.Event() prog = Adw.AlertDialog(heading="Calibrating…", body="🤫 Stay silent…") prog.add_response("cancel", "Cancel") def _on_cancel(d, result): d.choose_finish(result) - state["cancelled"] = True + # Read inside the capture loop, not merely between the two + # captures: Cancel used to hide the dialog and leave the + # recording running to the end of its ten seconds. + cancelled.set() prog.choose(self, None, _on_cancel) def _work(): floor_m = calibrate.metrics_from_raw( - calibrate.capture_raw(node, calibrate.FLOOR_SECONDS)) - if state["cancelled"]: - return None + calibrate.capture_raw(node, calibrate.FLOOR_SECONDS, + cancel=cancelled.is_set)) GLib.idle_add(prog.set_body, "🗣 Now speak normally…") speech_m = calibrate.metrics_from_raw( - calibrate.capture_raw(node, calibrate.SPEECH_SECONDS)) - if state["cancelled"]: - return None + calibrate.capture_raw(node, calibrate.SPEECH_SECONDS, + cancel=cancelled.is_set)) result = calibrate.analyze( floor_m["peaks_db"], speech_m["peaks_db"]) result["fx"].update(calibrate.analyze_tone(floor_m, speech_m)) return result def _done(result): + self._calibrating.discard(source_id) prog.force_close() if result is None: return @@ -1868,9 +1961,17 @@ def _done(result): if source is None: return source["fx"] = {**sources_module.fx(source), **result["fx"]} - cell.set_fx(sources_module.fx(source)) + # Re-resolved, never the row captured ten seconds ago: a reorder + # or a second Wave appearing rebuilds every row, and writing the + # result into the detached one leaves the visible popover on the + # old values — which the next FX touch then writes back over the + # calibration. + cell = self.matrix.source(source_id) + if cell is not None: + cell.set_fx(sources_module.fx(source)) sources_module.save(self._sources) self.mixer.set_sources(self._sources) + self._push_remote_state() m, f = result["measured"], result["fx"] tone = f"Low cut {f.get('lowcut', 0)} Hz" if f.get("eq_high"): @@ -1887,10 +1988,19 @@ def _done(result): f"{tone}."), ) report.add_response("ok", "OK") + # A window closed to the tray mid-calibration would host both + # this and the progress dialog invisibly: no way to dismiss + # either, and a stale "Calibrating…" waiting on the next unhide. + if not self.get_visible(): + self.present() report.choose(self, None, lambda d, r: d.choose_finish(r)) def _fail(exc): + self._calibrating.discard(source_id) prog.force_close() + # Cancel is not a failure, and it already closed its own dialog. + if isinstance(exc, calibrate.CalibrationCancelled): + return err = Adw.AlertDialog(heading="Calibration failed", body=str(exc)) err.add_response("ok", "OK") err.choose(self, None, lambda d, r: d.choose_finish(r)) @@ -1911,12 +2021,17 @@ def _on_source_fx_changed(self, cell, source_id): if prev is not None: GLib.source_remove(prev) - def _apply(sid=source_id, c=cell): + def _apply(sid=source_id): self._fx_debounce_ids.pop(sid, None) source = self._sources.get(sid) - if source is None: + # Re-resolved rather than captured: a reorder within the debounce + # window replaces the row, and the detached widget still holds + # whatever it showed before — which would be written back over a + # calibration that landed in the meantime. + row = self.matrix.source(sid) + if source is None or row is None: return GLib.SOURCE_REMOVE - source["fx"] = c.fx_settings() + source["fx"] = row.fx_settings() sources_module.save(self._sources) self.mixer.set_sources(self._sources) self._push_remote_state() @@ -2868,11 +2983,15 @@ def do_shutdown(self): subprocesses before the process exits.""" if self._window is not None: self._window._save_ui_state() - self._window._stop_polling() + self._window._stop_timers() if hasattr(self._window, "meter"): self._window.meter.stop_all() if hasattr(self._window, "mixer"): self._window.mixer.stop() + # Before the handle closes: a worker inside a control transfer + # when the device is disconnected out from under it is the + # classic crash-on-quit. + self._window._join_workers() self._window.dev.disconnect() Adw.Application.do_shutdown(self) diff --git a/wavexlr/calibrate.py b/wavexlr/calibrate.py index 5b50a42..520cb30 100644 --- a/wavexlr/calibrate.py +++ b/wavexlr/calibrate.py @@ -10,65 +10,65 @@ """ import math +import os +import select import struct import subprocess +import time + +from .mixer import _set_pdeathsig # same child-dies-with-us rule as the meters RATE = 48000 -WINDOW = 1600 # ~33 ms of s16 mono @ 48 kHz +WINDOW = 1600 # 800 s16 mono samples — 16.7 ms @ 48 kHz FLOOR_SECONDS = 3 SPEECH_SECONDS = 5 +# How long past its own duration a capture is given before it is called +# stalled. pw-cat delivers in real time, so anything beyond this is a node +# that stopped producing rather than a slow one. +GRACE_SECONDS = 3 +# Longest a single read may block, and so the worst-case latency of a cancel. +_POLL_SECONDS = 0.25 class CalibrationError(Exception): pass -def capture_window_peaks_db(node_name, seconds): - """Per-window peak dBFS from `seconds` of one node, transient skipped.""" - # pw-cat records until killed; the duration is ours to enforce by - # reading exactly the byte budget and then stopping the child. - budget = RATE * 2 * seconds + RATE # + half a second of transient - try: - proc = subprocess.Popen( - ["pw-cat", "--record", "--target", node_name, - "--rate", str(RATE), "--channels", "1", "--format", "s16", "-"], - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - ) - except OSError as exc: - raise CalibrationError(f"could not record {node_name}: {exc}") - chunks, got = [], 0 - try: - while got < budget: - chunk = proc.stdout.read(min(65536, budget - got)) - if not chunk: - break - chunks.append(chunk) - got += len(chunk) - finally: - proc.terminate() - try: - proc.wait(timeout=2) - except subprocess.TimeoutExpired: - proc.kill() - raw = b"".join(chunks)[RATE:] # drop the connection transient - peaks = [] - for i in range(0, len(raw) - WINDOW, WINDOW): - n = WINDOW // 2 - samples = struct.unpack(f"<{n}h", raw[i:i + WINDOW]) - peak = max(abs(s) for s in samples) / 32768.0 - peaks.append(20 * math.log10(max(peak, 1e-7))) - if len(peaks) < seconds * 10: - raise CalibrationError( - f"{node_name} delivered almost no audio — is the device stalled?") - return peaks +class CalibrationCancelled(Exception): + """The caller asked for the capture to stop before it finished.""" -def capture_raw(node_name, seconds, channels=2): - """Exactly `seconds` of raw s16 off one node, transient dropped. +def _read_exactly(proc, budget, deadline, cancel): + """Up to `budget` bytes from `proc`, honouring a deadline and a cancel. - Stereo by default: channel balance is one of the things calibration - can judge, and a mono device simply delivers two equal channels. + A plain `read()` on the pipe was the bug this exists to avoid: pw-cat + neither exits nor delivers when its node goes away mid-capture (an + unplugged microphone, a suspended device), so the read blocked forever + — a worker thread parked for good and a modal "Calibrating…" that could + never be dismissed. Polled instead, so both the clock and the Cancel + button can end it. """ + chunks, got = [], 0 + while got < budget: + if cancel is not None and cancel(): + raise CalibrationCancelled() + if time.monotonic() > deadline: + break + ready, _, _ = select.select([proc.stdout], [], [], _POLL_SECONDS) + if not ready: + continue + # os.read, not stdout.read: the latter blocks until it has the full + # count, which is the blocking this loop exists to avoid. + chunk = os.read(proc.stdout.fileno(), min(65536, budget - got)) + if not chunk: + break + chunks.append(chunk) + got += len(chunk) + return b"".join(chunks) + + +def _capture(node_name, seconds, channels, cancel): + """`seconds` of s16 off one node as raw bytes, transient included.""" frame = 2 * channels budget = RATE * frame * seconds + RATE * frame // 2 try: @@ -77,24 +77,45 @@ def capture_raw(node_name, seconds, channels=2): "--rate", str(RATE), "--channels", str(channels), "--format", "s16", "-"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + preexec_fn=_set_pdeathsig, ) except OSError as exc: raise CalibrationError(f"could not record {node_name}: {exc}") - chunks, got = [], 0 + deadline = time.monotonic() + seconds + GRACE_SECONDS try: - while got < budget: - chunk = proc.stdout.read(min(65536, budget - got)) - if not chunk: - break - chunks.append(chunk) - got += len(chunk) + return _read_exactly(proc, budget, deadline, cancel) finally: - proc.terminate() + # Reaped, not merely signalled: an unreaped pw-cat is a zombie for + # the life of the app, and one left running holds a stream open on + # the node that health.py then reads as a stalled device. try: + proc.terminate() proc.wait(timeout=2) except subprocess.TimeoutExpired: proc.kill() - raw = b"".join(chunks)[RATE * frame // 2:] + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + except (OSError, ProcessLookupError): + pass + try: + proc.stdout.close() + except OSError: + pass + + +def capture_raw(node_name, seconds, channels=2, cancel=None): + """Exactly `seconds` of raw s16 off one node, transient dropped. + + Stereo by default: channel balance is one of the things calibration + can judge, and a mono device simply delivers two equal channels. + + `cancel`, if given, is polled while reading; when it returns True the + child is stopped and CalibrationCancelled is raised. + """ + frame = 2 * channels + raw = _capture(node_name, seconds, channels, cancel)[RATE * frame // 2:] if len(raw) < RATE * frame * seconds // 2: raise CalibrationError( f"{node_name} delivered almost no audio — is the device stalled?") @@ -183,6 +204,10 @@ def analyze_tone(floor_metrics, speech_metrics): def _percentile(values, pct): + # Empty in means ordered[-1] — the largest value — silently standing in + # for a percentile of nothing. Say so instead. + if not values: + raise CalibrationError("nothing was measured — is the device stalled?") ordered = sorted(values) return ordered[min(len(ordered) - 1, int(len(ordered) * pct / 100))] @@ -196,7 +221,9 @@ def analyze(floor_peaks_db, speech_peaks_db): """ floor = _percentile(floor_peaks_db, 50) voiced = [p for p in speech_peaks_db if p > floor + 10] - if len(voiced) < len(speech_peaks_db) * 0.1: + # `0 < 0` is False, so an empty speech capture would otherwise walk + # straight past the ratio test into a percentile of nothing. + if not voiced or len(voiced) < len(speech_peaks_db) * 0.1: raise CalibrationError( "I did not hear speech clearly above the noise floor — " "try again closer to the microphone.") diff --git a/wavexlr/meter.py b/wavexlr/meter.py index b8c29d5..ec778d7 100644 --- a/wavexlr/meter.py +++ b/wavexlr/meter.py @@ -121,6 +121,17 @@ def stop(self, source_id): proc.terminate() except (OSError, ProcessLookupError): return + # Reaped off the caller's thread. stop() is called from the GTK + # thread on every meter refresh, and the waits below are up to two + # seconds each: a main loop sitting in waitpid is a main loop not + # servicing its Wayland connection, which is how a window being + # moved around ends up killed for not draining its socket. + threading.Thread( + target=self._reap, args=(proc,), daemon=True, + ).start() + + @staticmethod + def _reap(proc): try: proc.wait(timeout=1) except subprocess.TimeoutExpired: @@ -155,7 +166,8 @@ def _reader(self, source_id, proc, stop_flag): data = proc.stdout.read(self.CHUNK_BYTES) if not data or len(data) < 2: break - self._last_data[source_id] = time.monotonic() + if source_id in self._procs: # not a meter already stopped + self._last_data[source_id] = time.monotonic() n = len(data) // 2 samples = struct.unpack(f"<{n}h", data[: n * 2]) peak = max(abs(s) for s in samples) / 32768.0 diff --git a/wavexlr/mixer.py b/wavexlr/mixer.py index 96a908b..a42e67f 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -1770,6 +1770,37 @@ def request_capture_poll(self): """ self._enqueue(("poll",), self._do_poll_capture_devices) + def request_stream_poll(self): + """poll_streams on the worker, for callers on the GTK thread. + + Same reasoning as request_capture_poll, and the same danger it was + written to avoid: poll_streams shells out to pw-dump with a 5 second + timeout, and it was being driven straight from a 2 second GLib + timeout. A main loop stuck in that call is a main loop not reading + the Wayland socket, which on a compositor that resizes and re-tiles + windows as they move is enough configure/enter/leave traffic to fill + the client buffer and get the connection cut -- the window vanishing + mid-drag, with no traceback anywhere. + + Its own key, so a stream poll never displaces a pending reconcile. + """ + self._enqueue(("stream-poll",), self.poll_streams) + + def request_volume_sync(self): + """Restore-then-observe the mix masters on the worker. + + Both halves call pactl; both were on the GTK thread every 2 seconds. + The gate order is preserved exactly: nothing is observed until a + restore has succeeded, or the unity the daemon just created the + sinks at would be persisted over the remembered values. + """ + self._enqueue(("volumes",), self._do_volume_sync) + + def _do_volume_sync(self): + if not self._volumes_restored: + self.restore_mix_volumes() + self.observe_mix_volumes() + def _do_poll_capture_devices(self): added, removed = self._refresh_live_captures() if added or removed: diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index ead8e3f..8de27fa 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -11,11 +11,28 @@ gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw, GObject, Gdk, Pango # noqa: E402 +from gi.repository import Gtk, Adw, GObject, Gdk, GLib, Pango # noqa: E402 from . import icons +def _emit_later(obj, signal, *args): + """Emit `signal` once the current GTK frame has unwound. + + For signals whose handlers dismantle the very thing that is emitting — + a popover being popped down, a row about to be destroyed by the reorder + its own drop handler asks for. GTK is still inside the controller or the + popup teardown at that moment, and pulling the widget out from under it + is a use-after-free on a good day and an xdg_popup protocol error (which + kills the client outright) on Wayland. + """ + def _fire(): + obj.emit(signal, *args) + return GLib.SOURCE_REMOVE + + GLib.idle_add(_fire) + + def _percent_label(): """A fixed-width percentage readout for a 0..1 slider. @@ -235,7 +252,11 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, ) source.connect( "move-clicked", - lambda _s, delta, sid=source_id: self.emit("move-source-clicked", sid, delta), + # Deferred for the same reason as the drop path: the reorder this + # asks for destroys the row holding the button that was clicked, + # while GTK is still inside that button's own emission. + lambda _s, delta, sid=source_id: _emit_later( + self, "move-source-clicked", sid, delta), ) self._grid.attach(source, 0, row, 1, 1) self._sources[source_id] = source @@ -272,7 +293,11 @@ def _begin(_source, drag_obj, widget=cell): icon = Gtk.DragIcon.get_for_drag(drag_obj) paintable = Gtk.WidgetPaintable.new(widget) picture = Gtk.Picture.new_for_paintable(paintable) - picture.set_size_request(widget.get_width(), widget.get_height()) + # A row remapped but not yet allocated (a workspace switch, a + # window just unhidden) measures 0x0, and a 0x0 drag icon is an + # invisible drag. Same fallback the drop-zone maths uses. + picture.set_size_request(widget.get_width() or 320, + widget.get_height() or 64) icon.set_child(picture) widget.set_opacity(0.35) @@ -320,11 +345,15 @@ def _on_row_drop(self, _target, value, _x, y, target_id): self._clear_drop_hint(cell) if dragged == target_id or dragged not in self._source_ids: return False + # Both outcomes end in reorder_sources, which destroys every row — + # including this one, whose GtkDropTarget GTK is still emitting from + # and whose likeness the live GtkDragIcon is still painting. Deferred + # so the drop finishes against widgets that still exist. if cell is not None and self._drop_is_grouping(cell, y): - self.emit("group-sources-clicked", dragged, target_id) + _emit_later(self, "group-sources-clicked", dragged, target_id) return True delta = self._source_ids.index(target_id) - self._source_ids.index(dragged) - self.emit("move-source-clicked", dragged, delta) + _emit_later(self, "move-source-clicked", dragged, delta) return True def set_source_group(self, source_id, group): @@ -983,9 +1012,13 @@ def row(label, widget): box.append(r) auto_btn = Gtk.Button(label="Auto-calibrate gate + comp") + # The handler opens a dialog; emitting inline would present it in the + # same frame as this popover's popup teardown, and a grab moving + # between a dying xdg_popup and a new one is the crash this button + # was reported for. auto_btn.connect("clicked", lambda _b: (pop.popdown(), - self.emit("fx-autotune"))) + _emit_later(self, "fx-autotune"))) box.append(auto_btn) self._fx_lowcut = Gtk.DropDown.new_from_strings(