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 new file mode 100644 index 0000000..b01d107 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,220 @@ +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, 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: + 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" + + # 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 }}" + 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 -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 . + + - name: Checksums + run: sha256sum openwave-*.tar.gz openwave_*.deb openwave-*.rpm > sha256sums.txt + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: release-objects + path: | + openwave-*.tar.gz + openwave_*.deb + openwave-*.rpm + 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 + + 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 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/.gitignore b/.gitignore index 94829cb..0a37fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc *.pyo .vscode/ +docs/comparison.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd10a24 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,261 @@ +# Changelog + +Notable changes per release. Format follows [Keep a Changelog](https://keepachangelog.com/); +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, + 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 + 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 + `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 + 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. +- **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 + 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) + 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. +- **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. +- **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. +- **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 40cc343..b6d9809 100644 --- a/Makefile +++ b/Makefile @@ -6,16 +6,18 @@ 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 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 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 @@ -25,14 +27,47 @@ install: 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 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 +# 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 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 + 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 +# 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/PKGBUILD b/PKGBUILD index 010d2e1..0a3f5bd 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,46 +1,26 @@ -# Maintainer: rikkichy +# Maintainer: Zedwil +# Contributor: rikkichy pkgname=openwave -pkgver=1.0.0 +pkgver=1.1.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" +url="https://github.com/NyleGarcia/openwave" license=('MIT') depends=('python' 'python-gobject' 'gtk4' 'libadwaita' 'libusb' 'pipewire') -source=("$pkgname-$pkgver.tar.gz::https://github.com/rikkichy/openwave/archive/refs/tags/v$pkgver.tar.gz") -sha256sums=('SKIP') +optdepends=('python-xlib: friendly app names in the Add Source picker') +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 } diff --git a/README.md b/README.md index be1f40f..f7366c5 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,246 @@ # 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. +[![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) + +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 | -|---|---|---| -| Wave XLR | `0fd9:007d` | Gain, mute, headphone volume, low impedance mode | -| 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 +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 -- **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 -- **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 -- **System tray** — Runs in background with tray icon, mute from tray menu -- **First-run setup** — Configures udev permissions and audio service automatically +### 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. 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 + "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. 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). + +### 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. +- **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 + 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, 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. +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 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. ## Install -One-liner — detects Arch, Debian/Ubuntu, Fedora, openSUSE, or Void; installs deps and OpenWave: +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 -curl -fsSL https://raw.githubusercontent.com/rikkichy/openwave/main/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/NyleGarcia/openwave/main/install.sh | sh ``` -Or from a checkout: +### Arch Linux ```bash -git clone https://github.com/rikkichy/openwave.git +yay -S openwave # AUR +``` + +### From a checkout + +```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 ``` -Uninstall: +### Nix + +The repo is a flake exposing `packages..openwave` (also `default`) +for `x86_64-linux` and `aarch64-linux`: + +```bash +nix run github:NyleGarcia/openwave +nix profile install github:NyleGarcia/openwave +``` + +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: + +```nix +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 +[`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 sudo make -C /path/to/openwave uninstall PREFIX=/usr/local @@ -54,6 +252,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 @@ -75,20 +276,172 @@ OpenWave detects your init system at runtime: - **other** (macOS, Windows, no init detected) — the capture-fix section is disabled. -### Start hidden in tray +### 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*. 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. + +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: + ```bash python3 -m wavexlr --hide ``` -### Start at login +## 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 -cp /usr/share/openwave/openwave-autostart.desktop ~/.config/autostart/ +python3 -m wavexlr.diag ``` -### Desktop entry -Copy `wavexlr.desktop` to `~/.local/share/applications/` for app launcher integration. +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. -## Architecture +## Repository layout ``` wavexlr/ @@ -99,13 +452,76 @@ 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 (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 + 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, troubleshooting +tests/ — unit suite (no GTK, no PipeWire, no hardware needed) +``` + +## Development + +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, 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 . +``` + +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). +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 `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 MIT diff --git a/com.github.openwave.metainfo.xml b/com.github.openwave.metainfo.xml new file mode 100644 index 0000000..e232383 --- /dev/null +++ b/com.github.openwave.metainfo.xml @@ -0,0 +1,56 @@ + + + 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/main/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 new file mode 100644 index 0000000..5b8d7b9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,245 @@ +# 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. + +### 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: + +- **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. + +## 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 | +|---|---|---| +| `~/.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 | +| `openwave__mix_source` | a mix published as a capture source | + +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. + +## 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 +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 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/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/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. 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/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..324b15a Binary files /dev/null and b/docs/screenshot.png differ 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/flake.nix b/flake.nix index f90f0dc..54d13d3 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"; @@ -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 @@ -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 @@ -87,7 +86,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/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/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/packaging/flatpak/com.github.openwave.yml b/packaging/flatpak/com.github.openwave.yml new file mode 100644 index 0000000..2b13741 --- /dev/null +++ b/packaging/flatpak/com.github.openwave.yml @@ -0,0 +1,164 @@ +# 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: "49" +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 + # 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: + # 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: + - type: git + 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 + 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 + + # 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 + config-opts: + - -Dsystemd=disabled + - -Delogind=disabled + - -Ddoc=disabled + - -Dintrospection=disabled + - -Dtests=false + - -Dsystem-lua=true + 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: ../.. diff --git a/packaging/rpm/openwave.spec b/packaging/rpm/openwave.spec new file mode 100644 index 0000000..0a12672 --- /dev/null +++ b/packaging/rpm/openwave.spec @@ -0,0 +1,64 @@ +# 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 +# @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 +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 -n openwave-@SRCVER@ + +%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. 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 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..f970e04 --- /dev/null +++ b/plans/next/dsp-chain.md @@ -0,0 +1,130 @@ +# 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 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: +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 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 | + +## 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, + 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 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. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8eb0c8f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,28 @@ +"""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, scenes, 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") +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/support.py b/tests/support.py new file mode 100644 index 0000000..d1fe5a7 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,179 @@ +"""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._capture_mutes = {} + mx.mic = None + 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 + # 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() + 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): + self.calls.append(("ports", 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_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_calibrate.py b/tests/test_calibrate.py new file mode 100644 index 0000000..4ac99fa --- /dev/null +++ b/tests/test_calibrate.py @@ -0,0 +1,221 @@ +"""Calibration analysis: measurements in, sane thresholds out.""" + +import os +import threading +import time +import unittest +from unittest import mock + +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) + + +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) + + +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/tests/test_config_render.py b/tests/test_config_render.py new file mode 100644 index 0000000..f57cbdb --- /dev/null +++ b/tests/test_config_render.py @@ -0,0 +1,125 @@ +"""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_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) + + 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() + + +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/tests/test_desktop.py b/tests/test_desktop.py new file mode 100644 index 0000000..2280111 --- /dev/null +++ b/tests/test_desktop.py @@ -0,0 +1,190 @@ +"""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", + "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(): + 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())) + + +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/tests/test_device_scaling.py b/tests/test_device_scaling.py new file mode 100644 index 0000000..f3ea1ba --- /dev/null +++ b/tests/test_device_scaling.py @@ -0,0 +1,127 @@ +"""Firmware-to-ALSA conversions for the Wave's own controls.""" + +import os +import shutil +import tempfile +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() + + +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/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/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/tests/test_fx.py b/tests/test_fx.py new file mode 100644 index 0000000..1fe148d --- /dev/null +++ b/tests/test_fx.py @@ -0,0 +1,178 @@ +"""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_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: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)) + 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") + 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") + + 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") + 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.mx._sources["dock"]["fx"] = {} + self.mx._reconcile_fx("dock") + self.mx._reconcile_cell("dock", "chat") + 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 + 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] + 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/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..09726b6 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,329 @@ +"""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 unittest import mock + +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_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.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]) + 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_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.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__": + unittest.main() 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/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_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_mix_volumes.py b/tests/test_mix_volumes.py new file mode 100644 index 0000000..4a22bf2 --- /dev/null +++ b/tests/test_mix_volumes.py @@ -0,0 +1,303 @@ +"""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._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): + 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) + + +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)) + + 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/tests/test_mixer_reconcile.py b/tests/test_mixer_reconcile.py new file mode 100644 index 0000000..b2b5a4f --- /dev/null +++ b/tests/test_mixer_reconcile.py @@ -0,0 +1,218 @@ +"""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_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" + 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/tests/test_mixer_state.py b/tests/test_mixer_state.py new file mode 100644 index 0000000..eed7f9a --- /dev/null +++ b/tests/test_mixer_state.py @@ -0,0 +1,226 @@ +"""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 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"), + "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"))) + 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_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/tests/test_no_elgato.py b/tests/test_no_elgato.py new file mode 100644 index 0000000..1d8478a --- /dev/null +++ b/tests/test_no_elgato.py @@ -0,0 +1,158 @@ +"""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 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 = { + "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() 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/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/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..3f7ce98 --- /dev/null +++ b/tests/test_recovery.py @@ -0,0 +1,246 @@ +"""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 + + +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") + + +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() + + +@unittest.skipUnless(_has_gi(), "PyGObject not available") +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")) + + +@unittest.skipUnless(_has_gi(), "PyGObject not available") +class TrayHostProbe(unittest.TestCase): + """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 + 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/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/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/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() diff --git a/tests/test_stores.py b/tests/test_stores.py new file mode 100644 index 0000000..8a07d66 --- /dev/null +++ b/tests/test_stores.py @@ -0,0 +1,193 @@ +"""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() + + +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/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/tests/test_tray.py b/tests/test_tray.py new file mode 100644 index 0000000..0dd3c84 --- /dev/null +++ b/tests/test_tray.py @@ -0,0 +1,178 @@ +"""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 + +# 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): + """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() + + +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.desktop b/wavexlr.desktop index 86c48f4..d6909fe 100644 --- a/wavexlr.desktop +++ b/wavexlr.desktop @@ -1,7 +1,10 @@ [Desktop Entry] +Type=Application Name=OpenWave -Comment=Elgato Wave Control for Linux +Comment=The audio mixing matrix for Linux Exec=openwave -Icon=audio-input-microphone -Type=Application -Categories=Audio;Settings; +Icon=openwave +Categories=AudioVideo;Audio;Mixer; +Terminal=false +StartupWMClass=com.github.openwave +X-GNOME-UsesNotifications=true diff --git a/wavexlr/app.py b/wavexlr/app.py index ff8791e..c1307c7 100644 --- a/wavexlr/app.py +++ b/wavexlr/app.py @@ -5,62 +5,267 @@ gi.require_version('Adw', '1') from gi.repository import Gtk, Adw, GLib, GObject, Gio, Gdk +import json import logging import os import sys import threading +import time from .device import WaveDevice from .meter import MeterMonitor -from .mixer import Mixer +from .mixer import ( + 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 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, desktop as desktop_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") 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) - self.set_size_request(900, 520) - self.dev = WaveDevice() + # 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() # 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 self._last_state = None self._poll_id = None + self._reconnect_id = None self._stream_poll_id = None - self._gain_timeout = None - self._hp_timeout = None - self._mix_timeout = None + self._device_poll_countdown = self._DEVICE_POLL_EVERY + # 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 = {} - self._sources = sources_module.load() + 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() + _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() + self._restore_gain_lock() self._update_service_status() self.mixer = Mixer() + 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 + # 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() + self._autodiscover_elgato_inputs() + self._refresh_mix_emptiness() self._start_meters() 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. + _UI_STATE = os.path.expanduser("~/.config/openwave/ui-state.json") + + 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._UI_STATE) as f: + state = json.load(f) + except (OSError, ValueError): + 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: + 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() + + 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._UI_STATE), exist_ok=True) + state = { + "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( + getattr(self, "gain_lock", None) and self.gain_lock.get_active() + ), + } + 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"]) + tmp = self._UI_STATE + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, self._UI_STATE) + except OSError: + pass + def _build_ui(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.set_content(box) # 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 + # 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) + + # 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()) @@ -70,7 +275,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) @@ -99,29 +306,20 @@ 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", - icon_name="audio-input-microphone-symbolic", - has_level=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(): @@ -130,11 +328,26 @@ 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) + or sources_module.kind(source) + == sources_module.KIND_DEVICE), + editable=True, + reorderable=True, + is_capture=sources_module.kind(source) == sources_module.KIND_DEVICE, ) + 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) + 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) + 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( @@ -155,25 +368,18 @@ 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.""" + # --- 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") @@ -185,9 +391,20 @@ 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) + + # 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( @@ -196,10 +413,16 @@ 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)) + + 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") @@ -224,10 +447,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) @@ -250,54 +471,138 @@ 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. + + # --- 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 --- - 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) + + # --- 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()) + 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() + 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): - """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( @@ -321,6 +626,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(): @@ -331,33 +677,180 @@ 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): - self.status_label.set_label("Connecting...") + # 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.status_label.remove_css_class("dim-label") 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() + # 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.status_label.set_label("Disconnected") - self.status_label.add_css_class("dim-label") + 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. + + 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: @@ -369,24 +862,158 @@ 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 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 + 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) + 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() + self._push_remote_state() + + 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.status_label.set_label("Disconnected") - self.status_label.add_css_class("dim-label") + 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._select_surviving_device() def _apply_profile(self, profile): """Adapt the UI to the connected device model.""" @@ -394,12 +1021,16 @@ 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.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) - self.status_label.set_label(f"OpenWave — {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._window_title.set_subtitle(profile.display_name) def _format_gain(self, raw): scale = self.dev.profile.gain_scale if self.dev.profile else None @@ -418,20 +1049,40 @@ 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: 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"]) + # 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") + """A write to the selected device failed: drop that unit only.""" self.dev.disconnect() - self._stop_polling() + 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: @@ -444,29 +1095,241 @@ 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): + """(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, default_sink=default_sink, + ) + descriptions = {sink["name"]: sink["description"] for sink in sinks} + + auto_label = "Automatic" + 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. + + 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, + 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 + 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}" + 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 + # 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() + self._refresh_mix_emptiness() + + 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 + 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 + # 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 + 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 _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._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) + 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: @@ -474,19 +1337,22 @@ 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 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: @@ -496,9 +1362,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: @@ -510,9 +1374,9 @@ 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 ("personal", "chat", "record"): + for mix_id in self._mixes: self._wire_cell(source_id, mix_id) def _wire_cell(self, source_id, mix_id): @@ -531,40 +1395,214 @@ 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) + # 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: + 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: + self._refresh_device_meter(source_id, source) + 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 _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. + + 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 + 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( + 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) + + 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. + 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): - """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) + + 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") + # 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. + 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.""" + """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( @@ -573,42 +1611,1020 @@ 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): + 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() + 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 _on_source_confirmed(self, _dialog, name, match_app_name, icon_name): + 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, + 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 + } + # 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, + 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, + 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() + 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.""" self._sources = sources_module.add(self._sources, source) self.matrix.add_source( source["id"], name=source["name"], icon_name=source["icon_name"], has_level=True, - removable=True, + 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, ) - self._wire_cell(source["id"], "personal") - self._wire_cell(source["id"], "chat") - self._wire_cell(source["id"], "record") + 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) + # 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() + + 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. + + 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. + + 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 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 + # 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 devices: + 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. + + 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 + # Protected device rows carry a remove button that starts hidden; + # the presence tick shows it only while the device is unplugged. + 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) + 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))) + 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) + + 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 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. + + 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 + # 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): + 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(source_id, node) + + intro.choose(self, None, _go) + + def _calibrate_run(self, source_id, node): + from . import calibrate + 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) + # 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, + 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, + 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 + source = self._sources.get(source_id) + if source is None: + return + source["fx"] = {**sources_module.fx(source), **result["fx"]} + # 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"): + 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.\n" + 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)) + + self._usb_async(_work, on_done=_done, on_error=_fail) + + _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): + self._fx_debounce_ids.pop(sid, None) + source = self._sources.get(sid) + # 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"] = row.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( + 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) + self._sync_hw_mute(self._sources.get(source_id, {}), muted) + if not muted: + 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. + + 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 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) + self._push_remote_state() + 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: + # 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)), + 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. + + 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) + self._sync_hw_mute(source, muted) + if not muted: + 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): + """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() + self._push_remote_state() + 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() + self._push_remote_state() + 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() + self._push_remote_state() + 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)) + 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): + """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({ + "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), + # 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() + ], + "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(), + }) + + def _on_switch_source_clicked(self, _matrix, source_id): + """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 + + 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._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. + + 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) + # 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) + 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)) + for sid in self._sources: + self._wire_source_row(sid) + 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: + 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, + group=""): + 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 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. + 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 + # `binding` arrives empty for that flow. + # 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) + + 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 + # 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._refresh_app_meter(source["id"]) + 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") + 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") @@ -619,6 +2635,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) @@ -643,11 +2667,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): @@ -663,6 +2689,246 @@ 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) + + 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", "{}"), + ) + 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) + + 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 + 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_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. + + 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 _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 _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() @@ -677,13 +2943,19 @@ 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) 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): @@ -707,12 +2979,20 @@ 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._save_ui_state() + 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) def _on_close_request(self, window): @@ -722,15 +3002,49 @@ 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() + 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), + # 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(), + ) def _toggle_mute(self): if self._window and self._window.dev.connected: @@ -745,12 +3059,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", @@ -803,12 +3129,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/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/calibrate.py b/wavexlr/calibrate.py new file mode 100644 index 0000000..520cb30 --- /dev/null +++ b/wavexlr/calibrate.py @@ -0,0 +1,248 @@ +"""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 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 # 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 + + +class CalibrationCancelled(Exception): + """The caller asked for the capture to stop before it finished.""" + + +def _read_exactly(proc, budget, deadline, cancel): + """Up to `budget` bytes from `proc`, honouring a deadline and a cancel. + + 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: + proc = subprocess.Popen( + ["pw-cat", "--record", "--target", node_name, + "--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}") + deadline = time.monotonic() + seconds + GRACE_SECONDS + try: + return _read_exactly(proc, budget, deadline, cancel) + finally: + # 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() + 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?") + 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): + # 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))] + + +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] + # `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.") + 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/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/desktop.py b/wavexlr/desktop.py new file mode 100644 index 0000000..0fdde25 --- /dev/null +++ b/wavexlr/desktop.py @@ -0,0 +1,158 @@ +"""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" +# 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. +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 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]", + "Type=Application", + f"Name={NAME}", + f"Comment={COMMENT}", + f"Exec={exec_command}", + f"Icon={icon_name()}", + 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 diff --git a/wavexlr/device.py b/wavexlr/device.py index ea7b92f..761fa96 100644 --- a/wavexlr/device.py +++ b/wavexlr/device.py @@ -10,7 +10,10 @@ """ import ctypes +import glob +import os import ctypes.util +import re import struct import subprocess import threading @@ -40,12 +43,131 @@ ] _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 _find_card(matches): - """Find the ALSA card number for the device.""" +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. + + 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(): @@ -71,11 +193,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: @@ -85,24 +206,154 @@ 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.""" + 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", +# "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 = {} + + +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") + _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, 0-120).""" - _amixer(card, "cset", "numid=4", str(max(0, min(120, 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, 0-80).""" - _amixer(card, "cset", "numid=6", str(max(0, min(80, 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): - """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): @@ -130,32 +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._card = _find_card(profile.card_match) + self.usbbus = f"{bus:03d}/{addr:03d}" + self._card = _find_card( + 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, @@ -169,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, @@ -213,6 +522,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 @@ -240,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: @@ -292,6 +615,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(' 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, self._clean, self._delta, + 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. + # 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 + # 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 + 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 + 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, + 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._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: + # 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 + 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 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 + 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() + # 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: + 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) + 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 + 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 " + "(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 + 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 " + "(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: + try: + self.check_once() + except Exception as e: + log.error("health monitor error: %s", e) + time.sleep(CHECK_INTERVAL) diff --git a/wavexlr/icons.py b/wavexlr/icons.py new file mode 100644 index 0000000..af1234c --- /dev/null +++ b/wavexlr/icons.py @@ -0,0 +1,103 @@ +"""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. +""" + +# 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 +# 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 + 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 + 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/meter.py b/wavexlr/meter.py index 1bffe39..ec778d7 100644 --- a/wavexlr/meter.py +++ b/wavexlr/meter.py @@ -7,10 +7,12 @@ to keep concerns separate. """ +import json import os import struct import subprocess import threading +import time import gi @@ -22,25 +24,56 @@ 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): + # 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 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): + 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, + "--properties", json.dumps(props), "--rate", str(self.SAMPLE_RATE), "--channels", "1", "--format", "s16", @@ -63,8 +96,17 @@ 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 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: @@ -72,12 +114,24 @@ 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: 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: @@ -91,16 +145,46 @@ 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) if not data or len(data) < 2: break + 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 + 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 @@ -108,6 +192,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/mixdialog.py b/wavexlr/mixdialog.py new file mode 100644 index 0000000..f93d351 --- /dev/null +++ b/wavexlr/mixdialog.py @@ -0,0 +1,167 @@ +"""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 . import icons +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(icons.resolve(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 6e58ae6..a42e67f 100644 --- a/wavexlr/mixer.py +++ b/wavexlr/mixer.py @@ -15,10 +15,13 @@ import logging import os import signal +import re import subprocess +import sys import threading import time from threading import Event, Lock +from . import sources _log = logging.getLogger(__name__) @@ -36,20 +39,427 @@ _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_" + + +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} }}')) + # 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 ' + # 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", + '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} }}')) + 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", "")) + + 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) + # 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]}:{_ports(a)[1]}" ' + f'input = "{b[0]}:{_ports(b)[0]}" }}' + 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}" + + +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 _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) + 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: + subprocess.run( + ["pactl", "move-sink-input", str(serial), sink_name], + capture_output=True, text=True, timeout=3, + ) + except (FileNotFoundError, subprocess.SubprocessError): + 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 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) + + 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" + + def _set_pdeathsig(): if _libc is not None: _libc.prctl(_PR_SET_PDEATHSIG, int(signal.SIGTERM), 0, 0, 0) 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" + +# Reserved key in mixes.json holding the Personal Mix's output device. Cell +# 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. +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): @@ -63,19 +473,54 @@ 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 _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 "Elgato_Wave_" in 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]), - 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): @@ -130,6 +575,181 @@ 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 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 + description = props.get("node.description") or name + out.append({ + "name": 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 + +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 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() + + +# ----- 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 @@ -151,30 +771,194 @@ 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"], + # 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, "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 ------------------------------------------------- +# 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. + """ + 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 in wanted: + 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 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 + 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._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() self._sources = {} + self._mixes = {} self._streams = {} - self.mic, self.hp = find_wave_xlr_alsa() + # 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() + # {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() + # _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._volumes_restored = False + 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 @@ -219,18 +1003,119 @@ 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) + 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( @@ -238,15 +1123,95 @@ 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. - def streams(self): - """Snapshot of currently-known PipeWire output streams (id → info).""" - with self._lock: - return dict(self._streams) + 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} - # ----- subprocess lifecycle ----- - def _spawn_loopback(self, key, capture_source_name, playback_target, node_name): + def _default_output_for(self, mix_id): + """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.""" + outputs = self._state.get(OUTPUTS_STATE_KEY) or {} + return outputs.get(mix_id, self._default_output_for(mix_id)) + + 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. + + 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. + """ + 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} + + if choice and choice != OUTPUT_AUTO and choice in eligible: + return choice + + if self.hp and self.hp in eligible: + return self.hp + + if default_sink is None: + default_sink = self._pw.default_sink() + 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 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).""" + with self._lock: + return dict(self._streams) + + # ----- subprocess lifecycle ----- + def _spawn_loopback(self, key, capture_source_name, playback_target, + node_name, detach=False, playback_extra="", + 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 @@ -254,37 +1219,65 @@ 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 capture_node_name = f"{node_name}_cap" - try: - proc = subprocess.Popen( - [ - "pw-loopback", - "--capture-props=" - 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} " - "audio.channels=2 audio.position=[FL,FR]", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - preexec_fn=_set_pdeathsig, - ) - except (FileNotFoundError, OSError): + # 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)" ' + + # 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=" + + cap_props + + 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) + if not native_capture: + 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) @@ -292,15 +1285,11 @@ 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): + self._cell_capture.pop(key, None) proc = self._procs.pop(key, None) if proc is None: return @@ -318,8 +1307,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): @@ -343,8 +1337,21 @@ 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): + # 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): @@ -366,10 +1373,30 @@ 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.""" + 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)]: @@ -381,31 +1408,561 @@ 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. + """ + 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 + # 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. 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) 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): + volumes = self._state.get(VOLUMES_STATE_KEY) + if not isinstance(volumes, dict): + volumes = {} + 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: + 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 + + @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. + + 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 + # 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 = 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: + 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) + if not sink or remembered is None: + continue + volume, muted = remembered + self._pw.set_sink_volume(sink, volume) + self._pw.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 = self._pw.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. + + 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 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. + + 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() + 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): + """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",), + 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)) + # 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) + self._fx_failed.pop(sid, None) + + 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 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: + self._on_captures_moved(frozenset(added)) + + 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): self._sweep_stale_loopbacks() - if self.hp: - self._spawn_loopback( - HP_LOOPBACK_KEY, PERSONAL_MIX_SINK, self.hp, HP_LOOPBACK_NODE, - ) + self._sweep_orphan_source_sinks() + self._rescue_default_sink() + 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 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. + + 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 = self._pw.node_id(node_name) + if node_id is None: + return + 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.""" + 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 + 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, 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 + 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 = self._pw.default_sink() + 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: + self._pw.set_default_sink(target) + except (FileNotFoundError, subprocess.SubprocessError): + return + + 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, + 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)}" ' + ), + ) + 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 + # 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() + default_sink = self._pw.default_sink() + 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): + # 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 @@ -414,84 +1971,400 @@ def _do_remove_source(self, source_id): for k in keys: self._destroy_loopback(k) - @staticmethod - def _sweep_stale_loopbacks(): - try: - subprocess.run( - ["pkill", "-f", "pw-loopback.*openwave_loop_"], - 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 _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) + + 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) + + 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): + """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): - for source_id in (["mic"] + list(self._sources.keys())): - for mix_id in MIX_SINKS: + 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 + # unwired. + with self._lock: + # 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: + 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) + if self._fx_failed.get(source_id) == conf: + return # died under exactly these settings; wait for a change + proc = self._procs.get(key) + 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) + 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} ) + # 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: + 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, capture_node, + state["volume"], state["muted"], + ) + return + 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": - self._reconcile_mic_cell(mix_id, state["volume"], state["muted"]) - else: - self._reconcile_app_cell(source_id, mix_id, state["volume"], state["muted"]) + return f"openwave_loop_mic_to_{mix_id}" + return f"openwave_loop_dev_{source_id}_to_{mix_id}" - def _reconcile_mic_cell(self, mix_id, volume, muted): - if not self.mic: - return - mix_sink = MIX_SINKS.get(mix_id) - if not mix_sink: - return - key = ("mic", mix_id) - node_name = f"openwave_loop_mic_to_{mix_id}" - if volume <= 0.0: + 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 + # 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 + # 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: - self._spawn_loopback(key, self.mic, mix_sink, node_name) - node_id = _node_id_by_name(node_name) + with self._lock: + src_name = (self._sources.get(source_id) or {}).get( + "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, + 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 + node_id = self._pw.node_id(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") + # cell fader x source trim: the row slider scales this source + # everywhere, the cell decides how much of it this mix gets. + 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): - source = self._sources.get(source_id) - if not source: + """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) + source = sources.get(source_id) + if source is None: 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") - matching_stream_ids = { - sid for sid, s in self._streams.items() if s.get("app_name") == match - } - 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: + 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 + # 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: + 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 = 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. + 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") - # 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 = self._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 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): + """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: + 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.""" + from . import setup + name = source_sink_name(source_id) + try: + setup.create_null_sink( + name, f"OpenWave: {description}", priority=0, + ) + except Exception: + return None + 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): + """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/mixes.py b/wavexlr/mixes.py new file mode 100644 index 0000000..5cac49b --- /dev/null +++ b/wavexlr/mixes.py @@ -0,0 +1,184 @@ +"""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": "Send to voice apps", + "description": "OpenWave Chat Mix", + "sink": "openwave_chat_mix", + "icon_name": "system-users-symbolic", + }, + "record": { + "id": "record", + "name": "Record Mix", + "subtitle": "Send to OBS or a recorder", + "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 + + +_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. + + 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) + elif _clear_stale_subtitles(data): + 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 diff --git a/wavexlr/mixmatrix.py b/wavexlr/mixmatrix.py index 8903be8..8de27fa 100644 --- a/wavexlr/mixmatrix.py +++ b/wavexlr/mixmatrix.py @@ -5,11 +5,45 @@ mix routing are placeholders until PipeWire mix-sink backend lands (v0.3.0). """ +import logging + import gi 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, 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. + + 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): @@ -18,8 +52,25 @@ 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,)), + # (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,)), + # (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 + # 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") @@ -37,54 +88,185 @@ 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) 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 = {} 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" trailing affordance, lives below the grid + # "+ 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, - margin_start=12, margin_end=12, margin_bottom=12, + spacing=6, + 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(260, -1) 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.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( + "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), + ) + 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 add_source(self, source_id, *, name, icon_name, has_level=False, removable=False): + 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: + 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) + 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, 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, + name=name, icon_name=icon_name, has_level=has_level, + removable=removable, editable=editable, reorderable=reorderable, + is_capture=is_capture, ) + if editable: + source.connect( + "edit-clicked", + lambda _s, sid=source_id: self.emit("edit-source-clicked", sid), + ) if removable: source.connect( "remove-clicked", lambda _s, sid=source_id: self.emit("remove-source-clicked", sid), ) + 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", + # 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 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, reorderable=reorderable, + is_capture=is_capture, + ) for col_idx, mix_id in enumerate(self._mix_ids): cell = MixCell() @@ -93,6 +275,138 @@ def add_source(self, source_id, *, name, icon_name, has_level=False, removable=F 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) + # 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) + + 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("motion", self._on_row_motion, source_id) + drop.connect("leave", lambda _t, w=cell: self._clear_drop_hint(w)) + cell.add_controller(drop) + + # 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 + # 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): + _emit_later(self, "group-sources-clicked", dragged, target_id) + return True + delta = self._source_ids.index(target_id) - self._source_ids.index(dragged) + _emit_later(self, "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. + + 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`. + + 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. + """ + # 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 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 = [] + 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}) + def remove_source(self, source_id): if source_id not in self._source_ids: return @@ -100,6 +414,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) @@ -111,7 +426,19 @@ 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,)), + "volume-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), + "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 +447,347 @@ 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(icons.resolve(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) + + # 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, + 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(icons.resolve(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) - 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) + 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 + + 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") + + 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) + + 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(icons.resolve(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 + 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._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) + 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): @@ -159,16 +797,29 @@ 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,)), + # 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, ()), + # "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): + def __init__(self, *, name, icon_name, has_level, removable=False, + editable=False, reorderable=False, is_capture=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) + # 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, @@ -181,18 +832,81 @@ 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) + if reorderable: + 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(icons.resolve(icon_name)) + self._icon.set_pixel_size(26) + inner.append(self._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) + # 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) + + # 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") - 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") self._mute_btn.add_css_class("circular") - self._mute_icon = Gtk.Image.new_from_icon_name("audio-volume-high-symbolic") + # 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") + # 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. + 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) + + 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) @@ -206,10 +920,12 @@ def __init__(self, *, name, icon_name, has_level, removable=False): 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: @@ -228,29 +944,312 @@ 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: + 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( + 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) + + 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) + + __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) + + 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(), + _emit_later(self, "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) + 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) + + # 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, + 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, + "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()), + "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_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)) + 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 + + @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 blank the remove button on a row that owns one. + + Auto-discovered device rows are built with the button and normally + 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._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._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( + 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) + + def set_icon(self, 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. + + 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 _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.""" 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.""" @@ -259,9 +1258,29 @@ 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") + 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 already 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): + 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") @@ -271,6 +1290,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): @@ -325,27 +1345,58 @@ def __init__(self): hexpand=True, round_digits=2, ) + 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) + + 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): - 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) 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 diff --git a/wavexlr/profiles.py b/wavexlr/profiles.py index 11a543a..d26be60 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) @@ -32,6 +32,10 @@ class DeviceProfile: off_vol_select: int | None vol_select_map: dict off_low_z: int | None + # 48 V phantom power. 0x01 on, 0x00 off. 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. None on a device with no XLR input. + off_phantom: int | None off_monitor_mix: int | None mix_max: int card_match: tuple @@ -43,6 +47,10 @@ class DeviceProfile: def has_low_z(self): return self.off_low_z is not None + @property + def has_phantom(self): + return self.off_phantom is not None + @property def has_vol_select(self): return self.off_vol_select is not None @@ -69,7 +77,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=' 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) 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 diff --git a/wavexlr/scheduler.py b/wavexlr/scheduler.py new file mode 100644 index 0000000..dbc0f90 --- /dev/null +++ b/wavexlr/scheduler.py @@ -0,0 +1,101 @@ +"""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 +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 + +# 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) + 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 self._glib.timeout_add(int(interval_s * 1000), fn) + + def cancel(self, handle): + if handle is not None: + self._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() diff --git a/wavexlr/setup.py b/wavexlr/setup.py index a6384eb..b563140 100644 --- a/wavexlr/setup.py +++ b/wavexlr/setup.py @@ -1,17 +1,32 @@ """First-run setup: udev rule, WirePlumber rule, audio service.""" import os +import shutil import subprocess +import tempfile +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}=="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" +# 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 @@ -32,11 +47,17 @@ 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: 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 @@ -97,6 +118,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 @@ -138,11 +160,62 @@ 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"), -) +# 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. +# +# 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" + " node.description = " + + _spa_str(mix["description"]) + "\n" + " media.class = Audio/Sink\n" + " 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" + ) + return MIXES_HEADER + "\ncontext.objects = [\n" + "".join(entries) + "]\n" def _mix_sink_exists(name): @@ -157,20 +230,59 @@ def _mix_sink_exists(name): return any(line.split("\t", 2)[1] == name for line in r.stdout.splitlines() if "\t" in line) -def _create_mix_sink_live(name, description): +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, 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, priority=priority) + + +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} " - f'node.description="{description}" ' - "media.class=Audio/Sink " - "audio.position=[FL FR] " - "object.linger=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], @@ -182,26 +294,107 @@ 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 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. + + 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 + 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) - with open(MIXES_PATH, "w") as f: - f.write(content) - for name, desc in MIX_SINKS: - _create_mix_sink_live(name, desc) + + # 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 + + 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"]) return True 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(): diff --git a/wavexlr/sourcedialog.py b/wavexlr/sourcedialog.py index 101e2c4..e12ea5c 100644 --- a/wavexlr/sourcedialog.py +++ b/wavexlr/sourcedialog.py @@ -1,4 +1,15 @@ -"""'Add Source' picker — two pages: app picker, then name + icon config.""" +"""'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 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 @@ -6,7 +17,8 @@ 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 +from . import sources as sources_module ICON_CHOICES = ( ("applications-multimedia-symbolic", "Generic"), @@ -27,22 +39,229 @@ 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) + # (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, str)), } - def __init__(self): + def __init__(self, source=None, *, exclude_nodes=(), exclude_apps=()): 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) - self._selected_app = None - self._selected_icon = ICON_CHOICES[0][0] + # 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 + # 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] + + 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, + )) + + 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) - self._nav.push(self._build_picker_page()) + 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, + # 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): + 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._group_text(), + ) + self.close() # ------------------------------------------------------------ page 1 def _build_picker_page(self): @@ -54,10 +273,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) @@ -77,8 +292,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") @@ -97,17 +313,24 @@ 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") - empty.set_subtitle("Start playback in an app, then click + Add Source again") + 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) - return 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) @@ -115,22 +338,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): - page = Adw.NavigationPage(title="Name and Icon") + def _build_config_page(self, *, default_name=None, default_icon=None, + on_confirm=None, show_app_row=True): + """Shared final page for every source flow. + + 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. + """ + 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) @@ -138,10 +384,17 @@ def _build_config_page(self): 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", 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) @@ -160,10 +413,82 @@ 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( + (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. + # None on the capture-device page, a list on the application page. + self._bindings = None + if show_app_row: + app_group = Adw.PreferencesGroup( + title="Applications", + description="Audio from any of these is gathered under this " + "row's single fader.", + ) + outer.append(app_group) + + # 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", + ) + 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 + # 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 + 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) @@ -177,7 +502,16 @@ def _build_config_page(self): 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) btn.set_pixel_size(28) @@ -186,25 +520,137 @@ def _build_config_page(self): 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 == self._selected_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 + 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._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 _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: 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. + 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 + 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, self._group_text()) + else: + 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 9d5f8bb..cd7b421 100644 --- a/wavexlr/sources.py +++ b/wavexlr/sources.py @@ -1,9 +1,19 @@ """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 copy import json import os import uuid @@ -30,20 +40,268 @@ 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) -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 + + +# 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", + "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", + "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. + + 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 { "id": uuid.uuid4().hex[:12], + "kind": KIND_APP, "name": name, "match_app_name": match_app_name, "icon_name": icon_name, } +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. + + 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 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. + + `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, + } + + +# 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 + "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 + "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["gate"] or f["comp"] + 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) @@ -54,3 +312,54 @@ def remove(sources, source_id): sources.pop(source_id, None) 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. + + 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. + + 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 diff --git a/wavexlr/style.css b/wavexlr/style.css index faf0754..1ea7ba0 100644 --- a/wavexlr/style.css +++ b/wavexlr/style.css @@ -35,3 +35,36 @@ .openwave-mix-cell:disabled { opacity: 0.55; } +.openwave-source-waiting { + opacity: 0.55; +} + +.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; +} + +/* Exclusivity group badge on a source row. */ +.openwave-group-badge { + color: @accent_color; + 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; +} diff --git a/wavexlr/tray.py b/wavexlr/tray.py index 48c1b7f..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,11 +92,77 @@ """ +# 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.""" - 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 @@ -98,8 +171,37 @@ 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): + """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 +233,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 +248,59 @@ 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 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": @@ -157,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), } @@ -173,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"), @@ -232,8 +390,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: diff --git a/wavexlr/wmnames.py b/wavexlr/wmnames.py new file mode 100644 index 0000000..96094f4 --- /dev/null +++ b/wavexlr/wmnames.py @@ -0,0 +1,105 @@ +"""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 +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) diff --git a/wireplumber/51-openwave-wave-xlr.conf b/wireplumber/51-openwave-wave-xlr.conf index 6d4f370..836fd57 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 @@ -13,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 @@ -21,14 +32,15 @@ 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 = { session.suspend-timeout-seconds = 0 node.pause-on-idle = false audio.rate = 48000 + priority.driver = 2500 } } }