diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b2ac62b..3ccf942 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -23,7 +23,7 @@ body: options: - App UI - PipeWire routing or default output following - - WirePlumber integration + - pipewire-gobject integration - Analyzer or loudness monitoring - Presets or AutoEq/Equalizer APO import - GNOME Shell extension diff --git a/.github/ISSUE_TEMPLATE/install_or_packaging.yml b/.github/ISSUE_TEMPLATE/install_or_packaging.yml index 0314450..936eb73 100644 --- a/.github/ISSUE_TEMPLATE/install_or_packaging.yml +++ b/.github/ISSUE_TEMPLATE/install_or_packaging.yml @@ -70,6 +70,6 @@ body: id: extra attributes: label: Additional context - description: Mention whether GTK, Libadwaita, PipeWire, WirePlumber, and PipeWire JACK are installed when relevant. + description: Mention whether GTK, Libadwaita, PipeWire, WirePlumber, and pipewire-gobject are installed when relevant. validations: required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10a2c85..8f0f82c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: inputs: flatpak_runtime_smoke: - description: Run the experimental Flatpak PipeWire/WirePlumber routing smoke test + description: Run the experimental Flatpak PipeWire routing smoke test type: boolean default: false smoke_only: @@ -52,7 +52,7 @@ jobs: flatpak: ${{ steps.filter.outputs.flatpak }} test: ${{ steps.filter.outputs.test }} tooling: ${{ steps.filter.outputs.tooling }} - wp04: ${{ steps.filter.outputs.wp04 }} + pwg: ${{ steps.filter.outputs.pwg }} steps: - name: Detect changed files @@ -71,7 +71,7 @@ jobs: { echo "test=true" echo "tooling=true" - echo "wp04=true" + echo "pwg=true" echo "flatpak=true" } >> "$GITHUB_OUTPUT" } @@ -82,7 +82,7 @@ jobs: { echo "test=false" echo "tooling=false" - echo "wp04=false" + echo "pwg=false" echo "flatpak=false" } >> "$GITHUB_OUTPUT" exit 0 @@ -108,7 +108,7 @@ jobs: test=false tooling=false - wp04=false + pwg=false flatpak=false while IFS= read -r path; do @@ -116,7 +116,7 @@ jobs: .github/workflows/ci.yml) test=true tooling=true - wp04=true + pwg=true flatpak=true ;; README.md|pyproject.toml|MANIFEST.in|src/*|tests/*|data/*|extensions/*) @@ -134,13 +134,13 @@ jobs: esac case "$path" in - .github/workflows/ci.yml|docker/ubuntu-24.04-wp04.Dockerfile|tools/check_wireplumber_gi.py|src/mini_eq/wireplumber_backend.py) - wp04=true + .github/workflows/ci.yml|tools/check_pipewire_gobject.py|src/mini_eq/pipewire_backend.py|src/mini_eq/analyzer.py) + pwg=true ;; esac case "$path" in - .github/workflows/ci.yml|io.github.bhack.mini-eq.yaml|python3-dependencies.yaml|flatpak/*|src/*|data/*|pyproject.toml|MANIFEST.in|tools/check_wireplumber_gi.py) + .github/workflows/ci.yml|io.github.bhack.mini-eq.yaml|python3-dependencies.yaml|flatpak/*|src/*|data/*|pyproject.toml|MANIFEST.in|tools/check_pipewire_gobject.py) flatpak=true ;; esac @@ -149,7 +149,7 @@ jobs: { echo "test=$test" echo "tooling=$tooling" - echo "wp04=$wp04" + echo "pwg=$pwg" echo "flatpak=$flatpak" } >> "$GITHUB_OUTPUT" @@ -158,7 +158,7 @@ jobs: echo echo "- test: \`$test\`" echo "- tooling: \`$tooling\`" - echo "- wireplumber-0-4-compat: \`$wp04\`" + echo "- pipewire-gobject: \`$pwg\`" echo "- flatpak: \`$flatpak\`" } >> "$GITHUB_STEP_SUMMARY" @@ -220,24 +220,31 @@ jobs: sudo apt-get install -y \ gir1.2-adw-1 \ gir1.2-gtk-4.0 \ + gobject-introspection \ + libgirepository1.0-dev \ + libglib2.0-dev \ + libpipewire-0.3-dev \ + meson \ + ninja-build \ pipewire \ - pipewire-jack \ python3-cairo \ + python3-dev \ python3-gi \ python3-pip \ + python3-setuptools \ python3-venv \ wireplumber - if apt-cache show gir1.2-wp-0.5 >/dev/null 2>&1; then - sudo apt-get install -y gir1.2-wp-0.5 - else - sudo apt-get install -y gir1.2-wp-0.4 - fi - name: Install Python dependencies if: ${{ needs.changes.outputs.test == 'true' }} run: | + python3 -m venv /tmp/mini-eq-pwg-build + /tmp/mini-eq-pwg-build/bin/python -m pip install --upgrade pip + /tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.4,<0.4' -w /tmp/mini-eq-wheelhouse + python3 -m venv --system-site-packages .venv .venv/bin/python -m pip install --upgrade pip + .venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4' .venv/bin/python -m pip install -e '.[dev]' - name: Lint @@ -263,7 +270,9 @@ jobs: - name: Smoke-test wheel CLI if: ${{ needs.changes.outputs.test == 'true' }} run: | - python3 -m venv /tmp/mini-eq-wheel-test + python3 -m venv --system-site-packages /tmp/mini-eq-wheel-test + /tmp/mini-eq-wheel-test/bin/python -m pip install --upgrade pip + /tmp/mini-eq-wheel-test/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4' /tmp/mini-eq-wheel-test/bin/python -m pip install dist/mini_eq-*.whl /tmp/mini-eq-wheel-test/bin/mini-eq --help @@ -275,32 +284,6 @@ jobs: path: dist/* if-no-files-found: error - wireplumber-0-4-compat: - needs: changes - if: ${{ always() && !(github.event_name == 'workflow_dispatch' && inputs.smoke_only) }} - runs-on: ubuntu-24.04 - - steps: - - name: Require change detection - if: ${{ needs.changes.result != 'success' }} - run: exit 1 - - - name: Skip unchanged scope - if: ${{ needs.changes.outputs.wp04 != 'true' }} - run: echo "No WirePlumber 0.4 compatibility changes detected; skipping compatibility job work." - - - name: Check out repository - if: ${{ needs.changes.outputs.wp04 == 'true' }} - uses: actions/checkout@v6 - - - name: Build WirePlumber 0.4 compatibility image - if: ${{ needs.changes.outputs.wp04 == 'true' }} - run: docker build -f docker/ubuntu-24.04-wp04.Dockerfile -t mini-eq:wp04 . - - - name: Check WirePlumber 0.4 GI compatibility - if: ${{ needs.changes.outputs.wp04 == 'true' }} - run: docker run --rm mini-eq:wp04 - flatpak: needs: changes if: ${{ always() && !(github.event_name == 'workflow_dispatch' && inputs.smoke_only) }} @@ -345,11 +328,11 @@ jobs: if: ${{ needs.changes.outputs.flatpak == 'true' }} run: flatpak run io.github.bhack.mini-eq --help - - name: Check WirePlumber 0.5 GI compatibility + - name: Check pipewire-gobject GI compatibility if: ${{ needs.changes.outputs.flatpak == 'true' }} run: | flatpak run --filesystem="$PWD":ro --command=python3 io.github.bhack.mini-eq \ - "$PWD/tools/check_wireplumber_gi.py" --expect-version 0.5 + "$PWD/tools/check_pipewire_gobject.py" --expect-version 0.3.4 - name: Smoke-test Flatpak runtime modules if: ${{ needs.changes.outputs.flatpak == 'true' }} @@ -375,7 +358,7 @@ jobs: "/app/share/licenses/io.github.bhack.mini-eq/mini-eq/LICENSE", "/app/share/licenses/io.github.bhack.mini-eq/pipewire-filter-chain-module/COPYING", "/app/share/licenses/io.github.bhack.mini-eq/pipewire-filter-chain-module/LICENSE", - "/app/share/licenses/io.github.bhack.mini-eq/wireplumber/LICENSE", + "/app/share/licenses/io.github.bhack.mini-eq/pipewire-gobject/LICENSE", ] missing = [path for path in required_license_files if not Path(path).exists()] @@ -386,7 +369,7 @@ jobs: flatpak run --command=python3 io.github.bhack.mini-eq - <<'PY' import importlib - required_modules = ["numpy", "jack", "_jack"] + required_modules = ["numpy", "pipewire_gobject"] missing = [] for name in required_modules: diff --git a/AGENTS.md b/AGENTS.md index 3423b7a..3ccbb4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,9 @@ These notes apply to the whole repository. ## Project Context Mini EQ is a small system-wide parametric equalizer for PipeWire desktops. It -uses GTK/Libadwaita for the UI, WirePlumber for routing and default-output -monitoring, PipeWire filter-chain with builtin biquad filters for DSP, and the -PipeWire JACK compatibility layer plus NumPy for spectrum analysis. +uses GTK/Libadwaita for the UI, pipewire-gobject for app-facing PipeWire +routing, metadata, and monitor capture, PipeWire filter-chain with builtin +biquad filters for DSP, and NumPy for spectrum analysis. This is a public-facing repository. Treat every committed file, screenshot, artifact, and log snippet as public. Keep user-facing documentation focused on @@ -19,11 +19,12 @@ paths in ignored repo-local skills under `.agents/skills/`. - `src/mini_eq/core.py`: EQ data models, preset JSON, biquad math, APO import. - `src/mini_eq/filter_chain.py`: PipeWire filter-chain config generation. -- `src/mini_eq/wireplumber_backend.py`: WirePlumber GI compatibility layer. -- `src/mini_eq/wireplumber_stream_router.py`: stream routing helpers. +- `src/mini_eq/pipewire_backend.py`: pipewire-gobject-backed PipeWire registry, + metadata, and node-control layer. +- `src/mini_eq/pipewire_stream_router.py`: stream routing helpers. - `src/mini_eq/routing.py`: system-wide EQ lifecycle and routing controller. - `src/mini_eq/app.py` and `src/mini_eq/window*.py`: GTK/Libadwaita app and UI. -- `src/mini_eq/analyzer.py`: JACK/NumPy analyzer runtime. +- `src/mini_eq/analyzer.py`: pipewire-gobject monitor stream and NumPy analyzer runtime. - `src/mini_eq/screenshot.py` and `tools/render_demo_screenshot.py`: maintainer screenshot tooling, not user-facing CLI. - `data/`: desktop and AppStream metadata. @@ -70,18 +71,40 @@ desktop-file-validate data/io.github.bhack.mini-eq.desktop .venv/bin/python -m twine check dist/* ``` -The app depends on system GI/audio packages that Python packaging cannot -install: GTK4, Libadwaita, WirePlumber introspection, PipeWire, and PipeWire -JACK compatibility. Some tests skip automatically when optional runtime tools -are unavailable. +The app depends on system GI/audio packages that Python packaging cannot fully +install: GTK4, Libadwaita, PipeWire, a WirePlumber-managed session, and the +native libraries used by pipewire-gobject. PyGObject is a distro/runtime +dependency, not a Mini EQ PyPI dependency. Some tests skip automatically when +optional runtime tools are unavailable. + +For real GTK widget behavior, run the opt-in AT-SPI smoke test inside its +nested headless GNOME Shell session: + +```bash +MINI_EQ_RUN_ATSPI=1 .venv/bin/python -m pytest tests/test_mini_eq_atspi_widgets.py -q +``` + +For a deeper live runtime smoke, run the real GTK app in a private +PipeWire/WirePlumber graph with synthetic playback and AT-SPI UI driving: + +```bash +.venv/bin/python tools/check_live_ui_runtime.py --timeout 35 --cycles 1 +MINI_EQ_RUN_LIVE_UI=1 .venv/bin/python -m pytest tests/test_mini_eq_live_ui_runtime.py -q +``` + +When creating a fresh venv for pip/package validation, build pipewire-gobject in +a plain wheel-build venv first, then install that wheel into a +`--system-site-packages` Mini EQ venv. This keeps distro GI bindings visible at +runtime without making Ubuntu/Debian `g-ir-scanner` import partial `distutils` +modules from a system-site build venv. ## Change Guidelines - Prefer existing patterns and small, targeted patches. - Do not move logic between the large modules just to tidy them; split modules only when the user asked for that refactor or the change needs it. -- Keep WirePlumber 0.4 and 0.5 compatibility in mind. Do not use a newer GI API - without checking the compatibility layer and tests. +- Keep the pipewire-gobject API boundary small and app-facing. WirePlumber stays + the host session manager, not a bundled GI dependency. - Treat the Mini EQ D-Bus control interface as a project-internal app/Shell extension contract with version-skew tolerance. Keep `api_version = 1` additive only: add state fields, methods, and capabilities when needed, but do @@ -102,11 +125,15 @@ are unavailable. ## Flatpak And Flathub - Keep the upstream Flatpak manifest as a local development and CI manifest - using the checked-out source tree. The sibling Flathub repository uses a + using the checked-out source tree. The Flathub packaging repository uses a release archive URL and SHA-256 for publishing. -- Before opening a Flathub PR, compare the upstream and sibling Flathub - manifests. The only expected manifest difference is the Mini EQ source block: - local `type: dir` upstream, release archive URL and SHA-256 in Flathub. +- Before opening a Flathub PR, compare the upstream and Flathub manifests and + synced dependency files. The only expected manifest difference is the Mini EQ + source block: local `type: dir` upstream, release archive URL and SHA-256 in + Flathub. +- Treat Flathub publishing PRs as maintainer-owned. Agents may prepare local + diffs, validation output, and handoff notes, but should not open, submit, or + merge Flathub PRs. - Put user-facing Flatpak install information in `README.md`; keep Flathub release workflow and repository split notes in `docs/flathub.md`. - Do not hand-edit bundled Mini EQ source files in the Flathub repository. Fix @@ -169,35 +196,19 @@ During release preparation, verify that version-bearing files agree: URL. The package `__version__` is derived from release metadata and should not be hardcoded separately. -Before publishing artifacts, run `tools/release_preflight.py`; its focused leak -scan covers `HEAD`, tracked worktree changes, and untracked non-ignored text -files. If reproducing the scan manually, check all three surfaces instead of -only committed history: +Before publishing artifacts, run the release preflight. Prefer the +containerized wrapper when the host does not already have the `pipewire-gobject` +sdist build dependencies installed: ```bash -git rev-list --count HEAD -git ls-remote --heads origin -git ls-remote --tags origin -leak_pattern='(/home/|/Users/|secret|token|api[_-]?key|github_pat|gh[pousr]_[A-Za-z0-9_]{20,}|pypi-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|BEGIN [A-Z0-9 ]*PRIVATE KEY)' -git grep -n -I -E "$leak_pattern" HEAD -- . \ - ':(exclude)*.png' \ - ':(exclude)AGENTS.md' \ - ':(exclude)docs/release.md' \ - ':(exclude)tools/release_preflight.py' -git grep -n -I -E "$leak_pattern" -- . \ - ':(exclude)*.png' \ - ':(exclude)AGENTS.md' \ - ':(exclude)docs/release.md' \ - ':(exclude)tools/release_preflight.py' -git ls-files --others --exclude-standard -z -- . \ - | grep -z -v -E '(^|/)(AGENTS\.md|docs/release\.md|tools/release_preflight\.py)$|\.png$' \ - | xargs -0 -r grep -n -I -E "$leak_pattern" -- +tools/run_release_preflight_container.sh ``` -This grep is a focused privacy and credential smoke check, not a full secret -scanner. Keep GitHub secret scanning and push protection enabled; use an -external scanner such as Gitleaks for deeper local investigation when release -history or generated artifacts look suspicious: +Set `MINI_EQ_FLATHUB_MANIFEST` to a Flathub publishing manifest path when the +containerized preflight should include the manifest drift check. +The preflight owns the focused leak scan for `HEAD`, tracked worktree changes, +and untracked non-ignored text files. Use Gitleaks as an extra check when +release history or generated artifacts look suspicious: ```bash gitleaks git --no-banner --redact . diff --git a/MANIFEST.in b/MANIFEST.in index 39d41c0..2148e74 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,10 +14,8 @@ include docs/release.md include docs/social-preview.png include docs/screenshots/README.md include docs/screenshots/*.png -include docker/*.Dockerfile include extensions/gnome-shell/README.md recursive-include extensions/gnome-shell/mini-eq@bhack.github.io * -include flatpak/patches/*.patch include tools/*.py include tools/*.sh recursive-include tools/gnome-shell-extension *.py diff --git a/README.md b/README.md index 00b691d..fa94ab6 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Get it on Flathub Mini EQ is a small system-wide parametric equalizer for PipeWire desktops. -It uses GTK/Libadwaita for the UI, WirePlumber for routing/default-output -control, PipeWire filter-chain with builtin biquad filters for the equalizer, -and the JACK API on PipeWire plus NumPy FFT analysis for the analyzer. When -libebur128 is available, the monitor can also show live LUFS loudness. +It uses GTK/Libadwaita for the UI, pipewire-gobject for app-facing PipeWire +routing, metadata, and monitor streams, and PipeWire filter-chain with builtin +biquad filters for the equalizer. When libebur128 is available, the monitor can +also show live LUFS loudness. ![Mini EQ screenshot](https://raw.githubusercontent.com/bhack/mini-eq/main/docs/screenshots/mini-eq.png) @@ -19,10 +19,10 @@ libebur128 is available, the monitor can also show live LUFS loudness. - System-wide parametric EQ for PipeWire desktop playback. - GTK/Libadwaita interface with a compact 10-band fader workflow. -- WirePlumber routing and default-output tracking. +- PipeWire routing and default-output tracking through pipewire-gobject. - PipeWire filter-chain DSP using builtin biquad filters. -- Optional spectrum analyzer and LUFS loudness readout through the PipeWire JACK - compatibility layer. +- Optional spectrum analyzer and LUFS loudness readout through a PipeWire monitor + capture stream. - Per-output preset links for automatically using different saved presets with headphones, speakers, HDMI, and other outputs. - Optional background mode keeps the EQ active after closing the window, with a @@ -57,15 +57,20 @@ flatpak install flathub io.github.bhack.mini-eq flatpak run io.github.bhack.mini-eq ``` -Mini EQ depends on system desktop/audio packages that are not installed by -Python packaging: GTK 4.12+ and Libadwaita 1.7+ GI bindings, WirePlumber -introspection, PipeWire, and PipeWire JACK compatibility. +Mini EQ depends on system desktop/audio packages that Python packaging cannot +fully install: GTK 4.12+ and Libadwaita 1.7+ GI bindings, PyGObject, PipeWire, +WirePlumber as the session manager, and the native libraries required by +pipewire-gobject. Install PyGObject from your distro, such as `python3-gi` or +`python3-gobject`, rather than adding a PyPI PyGObject dependency to Mini EQ. If your distro ships older GTK or Libadwaita builds, prefer the Flatpak build. -Package names vary by distro release. Mini EQ prefers WirePlumber 0.5 -introspection when available and falls back to WirePlumber 0.4, which is what -Ubuntu 24.04 provides. +Package names vary by distro release. If pip builds pipewire-gobject from its +source distribution, install the GLib, GObject-Introspection, and PipeWire +development packages first. Virtual environments that need distro GI bindings +should use `--system-site-packages`; on Ubuntu/Debian, build the +pipewire-gobject wheel in a plain venv first, then install that wheel into the +system-site Mini EQ venv. These are good starting points: @@ -74,11 +79,19 @@ These are good starting points: sudo apt install \ gir1.2-adw-1 \ gir1.2-gtk-4.0 \ - gir1.2-wp-0.4 \ + gobject-introspection \ + libgirepository1.0-dev \ + libglib2.0-dev \ + libpipewire-0.3-dev \ + meson \ + ninja-build \ + pkg-config \ pipewire \ - pipewire-jack \ python3-cairo \ + python3-pip \ python3-gi \ + python3-setuptools \ + python3-venv \ wireplumber \ libebur128-1 @@ -86,36 +99,49 @@ sudo apt install \ sudo dnf install \ gtk4 \ libadwaita \ + gobject-introspection-devel \ + glib2-devel \ + meson \ + ninja-build \ + pkgconf-pkg-config \ pipewire \ - pipewire-jack-audio-connection-kit \ + pipewire-devel \ python3-cairo \ python3-gobject \ + python3-pip \ wireplumber \ - wireplumber-libs \ libebur128 # Arch Linux sudo pacman -S \ gtk4 \ libadwaita \ - libwireplumber \ + gobject-introspection \ + glib2 \ + meson \ + ninja \ + pkgconf \ pipewire \ - pipewire-jack \ python-cairo \ python-gobject \ + python-pip \ wireplumber \ libebur128 ``` -Use `gir1.2-wp-0.5` instead of `gir1.2-wp-0.4` on distro releases that package -WirePlumber 0.5 introspection. - Install the Python package after the system packages are present: ```bash -python3 -m pip install mini-eq -mini-eq --check-deps -mini-eq +python3 -m venv /tmp/mini-eq-pwg-build +/tmp/mini-eq-pwg-build/bin/python -m pip install --upgrade pip +/tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.4,<0.4' -w /tmp/mini-eq-wheelhouse + +python3 -m venv --system-site-packages ~/.local/share/mini-eq/venv +~/.local/share/mini-eq/venv/bin/python -m pip install --upgrade pip +~/.local/share/mini-eq/venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4' +~/.local/share/mini-eq/venv/bin/python -m pip install mini-eq +~/.local/share/mini-eq/venv/bin/mini-eq --check-deps +~/.local/share/mini-eq/venv/bin/mini-eq ``` For a source checkout: @@ -159,11 +185,10 @@ python3 -m pytest -q Some integration tests are skipped automatically when optional PipeWire runtime tools are not installed. -Check the Ubuntu 24.04 WirePlumber 0.4 GI compatibility surface in Docker: +Check the pipewire-gobject GI compatibility surface: ```bash -docker build -f docker/ubuntu-24.04-wp04.Dockerfile -t mini-eq:wp04 . -docker run --rm mini-eq:wp04 +PYTHONPATH=src python3 tools/check_pipewire_gobject.py ``` ## Flatpak @@ -171,9 +196,8 @@ docker run --rm mini-eq:wp04 The Flatpak manifest uses the GNOME runtime. It does not ship a full PipeWire daemon or session manager; it builds only the local PipeWire filter-chain module and SPA builtin filter-graph support that Mini EQ loads inside the app process. -The analyzer uses the runtime JACK compatibility library with bundled Python -JACK and NumPy dependencies. The Flatpak build also bundles libebur128 for the -live LUFS readout. +It also builds pipewire-gobject for Mini EQ's app-facing PipeWire access and +bundles NumPy and libebur128 for analyzer and live LUFS support. Install the local build tools: @@ -195,8 +219,8 @@ flatpak run io.github.bhack.mini-eq Runtime data is stored under `~/.config/mini-eq`. `pip install mini-eq` installs only the Python package. The system packages -above are still required for the app to connect to GTK, WirePlumber, PipeWire, -and PipeWire JACK. +above are still required for the app to connect to GTK, PipeWire, and the host +WirePlumber-managed session. ## Acknowledgements diff --git a/data/io.github.bhack.mini-eq.desktop b/data/io.github.bhack.mini-eq.desktop index 396d49f..c87a289 100644 --- a/data/io.github.bhack.mini-eq.desktop +++ b/data/io.github.bhack.mini-eq.desktop @@ -2,7 +2,7 @@ Name=Mini EQ GenericName=System-wide Equalizer Comment=Compact system-wide parametric equalizer for PipeWire -Keywords=equalizer;audio;pipewire;jack; +Keywords=equalizer;audio;pipewire; Categories=AudioVideo;Audio; Exec=mini-eq Icon=io.github.bhack.mini-eq diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml index 88d7e6c..81736bd 100644 --- a/data/io.github.bhack.mini-eq.metainfo.xml +++ b/data/io.github.bhack.mini-eq.metainfo.xml @@ -24,7 +24,6 @@ speakers presets pipewire - jack autoeq diff --git a/docker/preflight.Dockerfile b/docker/preflight.Dockerfile new file mode 100644 index 0000000..def2e57 --- /dev/null +++ b/docker/preflight.Dockerfile @@ -0,0 +1,41 @@ +FROM debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + appstream \ + build-essential \ + ca-certificates \ + dbus \ + desktop-file-utils \ + gir1.2-adw-1 \ + gir1.2-gtk-4.0 \ + git \ + gnome-shell \ + gobject-introspection \ + libebur128-1 \ + libgirepository1.0-dev \ + libglib2.0-dev \ + libpipewire-0.3-dev \ + libspa-0.2-modules \ + meson \ + ninja-build \ + pipewire \ + pkg-config \ + python3 \ + python3-cairo \ + python3-dev \ + python3-gi \ + python3-numpy \ + python3-pip \ + python3-setuptools \ + python3-venv \ + wireplumber \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/run-release-preflight.sh /usr/local/bin/mini-eq-release-preflight +RUN chmod +x /usr/local/bin/mini-eq-release-preflight + +WORKDIR /work +ENTRYPOINT ["mini-eq-release-preflight"] diff --git a/docker/run-release-preflight.sh b/docker/run-release-preflight.sh new file mode 100755 index 0000000..19d26ac --- /dev/null +++ b/docker/run-release-preflight.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +workdir="${MINI_EQ_PREFLIGHT_WORKDIR:-/work}" +venv="${MINI_EQ_PREFLIGHT_VENV:-/tmp/mini-eq-preflight-venv}" +runtime="${MINI_EQ_PREFLIGHT_RUNTIME:-/tmp/mini-eq-runtime}" + +cd "$workdir" +git config --global --add safe.directory "$workdir" 2>/dev/null || true + +rm -rf "$venv" "$runtime" +python3 -m venv --system-site-packages "$venv" +"$venv/bin/python" -m pip install --upgrade pip +"$venv/bin/python" -m pip install -e '.[dev]' + +mkdir -p "$runtime" +chmod 700 "$runtime" + +export MINI_EQ_PREFLIGHT_PYTHON="$venv/bin/python" +export XDG_RUNTIME_DIR="$runtime" + +dbus-run-session -- sh -eu -c ' +pipewire >/tmp/mini-eq-pipewire.log 2>&1 & +pipewire_pid=$! +wireplumber >/tmp/mini-eq-wireplumber.log 2>&1 & +wireplumber_pid=$! + +cleanup() { + kill "$wireplumber_pid" "$pipewire_pid" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +for _ in $(seq 1 50); do + if wpctl status >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +wpctl status >/dev/null +"$MINI_EQ_PREFLIGHT_PYTHON" tools/release_preflight.py "$@" +if [ -n "${MINI_EQ_FLATHUB_MANIFEST:-}" ]; then + "$MINI_EQ_PREFLIGHT_PYTHON" tools/check_flathub_manifest_drift.py \ + io.github.bhack.mini-eq.yaml \ + "$MINI_EQ_FLATHUB_MANIFEST" +fi +' sh "$@" diff --git a/docker/ubuntu-24.04-wp04.Dockerfile b/docker/ubuntu-24.04-wp04.Dockerfile deleted file mode 100644 index a271c66..0000000 --- a/docker/ubuntu-24.04-wp04.Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV PYTHONPATH=/src/src - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gir1.2-wp-0.4 \ - python3 \ - python3-gi \ - wireplumber \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src -COPY src/mini_eq ./src/mini_eq -COPY tools/check_wireplumber_gi.py ./tools/check_wireplumber_gi.py - -CMD ["python3", "tools/check_wireplumber_gi.py", "--expect-version", "0.4"] diff --git a/docs/flathub.md b/docs/flathub.md index cc95bec..b4c74e9 100644 --- a/docs/flathub.md +++ b/docs/flathub.md @@ -12,8 +12,8 @@ Use this note when maintaining the Mini EQ Flathub package. - Python Flatpak dependencies are generated in `python3-dependencies.yaml` with `flatpak-pip-generator`. - The manifest builds in project CI and installs the desktop file, AppStream - metadata, icons, licenses, PipeWire filter-chain module, WirePlumber - introspection, JACK client bindings, and NumPy. + metadata, icons, licenses, PipeWire filter-chain module, pipewire-gobject, + NumPy, and libebur128. - `flatpak-builder-lint manifest io.github.bhack.mini-eq.yaml` passes locally. ## Repository Split @@ -25,8 +25,8 @@ Keep the Flatpak packaging in both repositories, but keep the roles separate: - In the Flathub repository, `io.github.bhack.mini-eq.yaml` is the publishing manifest. It must point at an immutable public release archive and include the archive SHA-256. -- `python3-dependencies.yaml` and `flatpak/patches/` should normally stay in - sync between the two repositories. +- `python3-dependencies.yaml` should normally stay in sync between the two + repositories. - The Flathub repository's `master` branch is the source for the published `stable` Flatpak ref. Use pull requests for changes to protected publishing branches. @@ -51,6 +51,10 @@ Do not hand-edit bundled application source files in the Flathub repository. Fix application metadata, desktop files, icons, and source code upstream, cut a release, then update the Flathub manifest to the new release archive. +Flathub publishing PRs are maintainer-owned. Automated tools may prepare local +diffs, validation output, and handoff notes, but a maintainer should review the +packaging branch and open, submit, and merge the Flathub PR manually. + ## Release Update Checklist 1. Finish the upstream release and confirm the GitHub release is not a draft. @@ -63,13 +67,12 @@ release, then update the Flathub manifest to the new release archive. tag or commit URL before publishing. 6. Keep `python3-dependencies.yaml` unchanged unless Python dependencies changed. If dependencies changed, regenerate it and update both repositories. -7. Keep `flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch` available - in the Flathub repository when the manifest references it. -8. Run the validation commands below. -9. Open a pull request against the Flathub repository's `master` branch. -10. Install and test the temporary PR build posted by Flathub when practical. -11. Merge only after the Flathub PR build and checks pass. -12. Recheck the public listing and banner preview after publication: +7. Run the validation commands below. +8. As the maintainer, open a pull request against the Flathub repository's + `master` branch. +9. Install and test the temporary PR build posted by Flathub when practical. +10. Merge only after the Flathub PR build and checks pass. +11. Recheck the public listing and banner preview after publication: - `https://flathub.org/en/apps/io.github.bhack.mini-eq` - `https://flathub.org/en/apps/io.github.bhack.mini-eq/bannerpreview` @@ -78,6 +81,25 @@ committing the generated test build. A temporary `pending / Committing build...` status is normal; wait until the PR context becomes `success / Build ready` before merging. +## Beta And Test Builds + +Use Flathub PR test builds for normal release handoff validation. Flathub starts +a temporary test build for pull requests and the bot posts an installable bundle +when the build is ready; test that build before merging runtime-sensitive +changes. + +Use the Flathub `beta` branch only for release-candidate or high-risk changes +that need a user-installable Flatpak before the stable update. The Flathub +repository's `beta` branch maps to the `beta` Flatpak ref in the Flathub beta +remote; keep it temporary and move successful candidates to stable after the +test period. + +```bash +flatpak remote-add --if-not-exists flathub-beta https://flathub.org/beta-repo/flathub-beta.flatpakrepo +flatpak install flathub-beta io.github.bhack.mini-eq +flatpak run --branch=beta io.github.bhack.mini-eq +``` + GitHub draft releases expose assets under temporary `untagged-*` URLs. Publish the upstream GitHub release first, then use the stable asset URL: @@ -87,16 +109,23 @@ curl -fsSL \ | sha256sum ``` -Before opening the PR, compare the upstream and Flathub manifests from the -upstream checkout: +Before opening the PR, compare the upstream and Flathub manifests and synced +dependency files from the upstream checkout: ```bash -python3 tools/check_flathub_manifest_drift.py +python3 tools/check_flathub_manifest_drift.py \ + io.github.bhack.mini-eq.yaml \ + /path/to/flathub/io.github.bhack.mini-eq.yaml ``` -The command should report that the manifests match outside the Mini EQ source -stanza. Any other difference should be intentional and usually belongs in both -repositories. +The command should report that the manifests and dependency files match outside +the Mini EQ source stanza. Any other difference should be intentional and +usually belongs in both repositories. +The containerized release preflight runs the same drift check when +`MINI_EQ_FLATHUB_MANIFEST` points at the Flathub publishing manifest. +For dependency or permission migrations, keep the Flathub packaging checkout on +the staged update branch before final upstream preflight; otherwise the drift +check will correctly fail against the older stable manifest. The Flathub `master` branch is protected, so use a branch and pull request for publishing manifest updates. Keep local checkout paths, branch naming habits, @@ -126,7 +155,7 @@ flatpak run --command=flathub-build org.flatpak.Builder --install io.github.bhac flatpak run io.github.bhack.mini-eq --check-deps ``` -When routing, WirePlumber access, Flatpak permissions, runtime dependencies, or +When routing, PipeWire access, Flatpak permissions, runtime dependencies, or shutdown behavior changed, also run the upstream runtime smoke test against the installed local build before opening or merging the Flathub PR: @@ -163,7 +192,10 @@ flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo - The Flatpak bundles only the PipeWire filter-chain module and SPA builtin filter graph plugin needed inside the app process; it does not bundle or run a PipeWire daemon or session manager. -- WirePlumber is built for introspection compatibility, not as a bundled session - daemon. +- pipewire-gobject is bundled for app-facing PipeWire access; WirePlumber remains + a host session manager, not a bundled Flatpak daemon. +- When updating bundled pipewire-gobject, pin a published upstream tag plus its + peeled commit in the upstream manifest, then keep the Flathub manifest in sync + during the release handoff. - Runtime licenses for bundled modules are installed under `/app/share/licenses/io.github.bhack.mini-eq`. diff --git a/docs/release.md b/docs/release.md index ab3de51..7066a29 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,20 +1,37 @@ # Release -Use this checklist before publishing a public release. +Use this checklist before publishing a public Mini EQ release. Keep +owner-specific workflow dispatch commands, deployment approvals, and local +checkout paths in the ignored release skill, not in public documentation. Use the repository virtualenv when it exists. The examples use `python3`, but substitute `.venv/bin/python` in the local checkout when available. -## Verify Metadata +## Gate Map -Confirm the repository URLs in `pyproject.toml` match the actual GitHub repository. +Run the narrowest gate that covers the release risk: -```bash -git remote -v -gh auth status -``` +- **Always:** version metadata, release preflight, GitHub release dry run or + release workflow, TestPyPI install validation, PyPI publish, post-publish + verification, then Flathub stable PR. +- **When PipeWire, routing, analyzer, Flatpak permissions, runtime dependencies, + or shutdown changed:** local Flatpak install, Flatpak runtime smoke, and + interactive real-music testing before merge or release. +- **When background mode, Start at Login, hidden-window lifecycle, or Shell + control changed:** one clean-permission Flatpak portal smoke in a real GNOME + session. +- **When the GNOME Shell extension source changed:** run the extension checker, + build the review zip, test the supported Shell versions, and upload after the + app release is ready. +- **Only for high-risk release candidates:** use TestPyPI plus the Flathub + `beta` branch for broader install testing before final PyPI and Flathub + stable. + +TestPyPI is package-index validation. It is not a user beta channel. Flathub PR +test builds are the normal stable handoff validation. Flathub beta is a +temporary user-installable Flatpak beta, not a permanent second release line. -## Prepare Version Metadata +## Prepare Version Set the release version once for the shell session: @@ -23,13 +40,13 @@ version=X.Y.Z tag=v$version ``` -Mini EQ uses SemVer-style `X.Y.Z` versions, but it is still pre-`1.0.0`. -Use patch releases for fixes and listing/package polish, and minor releases -for user-facing features or workflow changes. Do not claim strict SemVer -stability until the app behavior, D-Bus control state, preset data, and Shell -extension contract are stable enough to document as a public API. +Mini EQ is pre-`1.0.0`. Use patch releases for fixes and listing/package polish, +and minor releases for user-facing features or workflow changes. Do not claim +strict SemVer stability until the app behavior, D-Bus control state, preset +data, and Shell extension contract are stable enough to document as a public +API. -Update every version-bearing file before building artifacts: +Update every version-bearing file: - `pyproject.toml` - `CHANGELOG.md` @@ -38,75 +55,61 @@ Update every version-bearing file before building artifacts: `mini_eq.__version__` is derived from release metadata and should not be bumped manually. -If the public app screenshot changed, make the AppStream screenshot URL point at -the same release tag. `docs/screenshots/mini-eq.png` is the README and -AppStream/Flathub screenshot, so it should remain a light/default app-window -screenshot. `docs/screenshots/mini-eq-dark.png` may be listed as a second -AppStream/Flathub screenshot to preview dark style support, but it should not -replace the light/default screenshot as the first/default image. -`docs/social-preview.png` is only for GitHub/social previews and may use branded -promotional styling. Then run the version metadata test: +If screenshots changed, keep `docs/screenshots/mini-eq.png` as the first/default +README and AppStream screenshot. It should be just the app window in the +platform-default light appearance. `docs/screenshots/mini-eq-dark.png` may be a +second screenshot. `docs/social-preview.png` is only for GitHub/social previews. + +Run the version metadata check: ```bash python3 -m pytest tests/test_version_metadata.py -q ``` -If the app icon SVG changed, visually inspect its 128, 64, and 32 px renders -on light and dark backgrounds before building the release. +If the app icon SVG changed, visually inspect its 128, 64, and 32 px renders on +light and dark backgrounds. Do not add generated PNG app icons unless a target +platform specifically needs them. -The app icon is installed as a scalable SVG plus a symbolic SVG. Do not add -generated PNG app icons unless a target platform specifically needs them. +## Run Preflight -For the full local release preflight, run: +Prefer the containerized preflight. It keeps host machines clean while testing a +fresh venv, `pipewire-gobject` sdist build dependencies, package metadata, a +private PipeWire/WirePlumber session, the leak scan, and release smoke checks: ```bash -python3 tools/release_preflight.py +tools/run_release_preflight_container.sh ``` -The preflight prints a GNOME Shell extension upload notice when the publishable -extension source changed since the relevant release tag. If it reports that an -upload may be needed, test the extension and upload the generated zip to -extensions.gnome.org after the release is ready. - -The preflight also prints a Flatpak background portal smoke notice when -background mode, Start at Login, hidden-window lifecycle, Shell control, or -Flatpak integration paths changed. That notice is change-aware; do the manual -portal smoke only for releases that touch those paths. - -## Check Locally +To include the Flathub handoff drift check, pass the publishing manifest path +explicitly: ```bash -python3 -m pip install -e '.[dev]' -python3 -m ruff check . -python3 -m ruff format --check . -python3 -m pytest -q -python3 -m mini_eq --check-deps +MINI_EQ_FLATHUB_MANIFEST=/path/to/flathub/io.github.bhack.mini-eq.yaml \ + tools/run_release_preflight_container.sh ``` -If the GNOME Shell extension changed, check the app/extension D-Bus contract -and build the review/upload bundle: +For dependency or Flatpak manifest migrations, stage the Flathub packaging +branch before final preflight; the drift check intentionally fails if the +Flathub manifest still has old bundled dependencies or permissions. + +To run preflight directly on Debian/Ubuntu hosts, install the build stack needed +by the `pipewire-gobject` sdist first: ```bash -python3 tools/check_gnome_shell_extension.py -tools/pack_gnome_shell_extension.sh +sudo apt install build-essential gobject-introspection libgirepository1.0-dev libglib2.0-dev libpipewire-0.3-dev pkg-config +python3 tools/release_preflight.py ``` -Only upload the generated extension zip after testing every GNOME Shell version -listed in `extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json`. Do -not list future Shell versions. - -If extensions.gnome.org asks for an extension screenshot, capture it from the -nested fake-control Shell documented in `extensions/gnome-shell/README.md` so no -real desktop, device, account, or path details are exposed. +The preflight prints change-aware notices for GNOME Shell extension upload, +Flatpak runtime smoke, and background portal smoke. Treat those notices as +release gates when they apply; the generic preflight deliberately does not +mutate the host audio graph. -## Flatpak Runtime Smoke +## Runtime Checks -Run this after installing the local Flatpak build, and before release -publication or Flathub handoff, whenever PipeWire routing, WirePlumber access, -Flatpak permissions, runtime dependencies, or shutdown behavior changed. This -check temporarily routes a silent host PipeWire stream through Mini EQ and then -verifies that the stream is restored when the app exits, so keep it separate -from the generic preflight. +Run this after installing the local Flatpak build whenever PipeWire routing, +`pipewire-gobject` access, Flatpak permissions, runtime dependencies, or +shutdown behavior changed: ```bash flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo @@ -137,191 +140,129 @@ Then enable **Keep Running in Background**, approve the portal prompt, enable the GNOME Shell extension. Use the Shell extension's **Show Mini EQ** and **Quit Mini EQ** actions to verify hidden-window recovery and full exit. -There is also an experimental non-blocking GitHub Actions path for this check: -manually dispatch the `CI` workflow with `flatpak_runtime_smoke=true` and, when -iterating only on the smoke harness, `smoke_only=true`. For quick harness -iterations against the published Flathub app, use `flatpak_runtime_build=false`, -`flatpak_runtime_install_remote=true`, and the default blank -`flatpak_runtime_app_ref` so the harness runs the installed Flathub ref. Set -`flatpak_runtime_expected_version` when using the published app, because Flathub -metadata can lag after an update. Leave `flatpak_runtime_build=true` for any -release check that must test current source or the local manifest. Use hosted -smoke as the validation for harness-only CI changes and as extra release signal; -keep the local runtime smoke as the release check when app/runtime routing -behavior changed. - -## Build - -```bash -rm -rf dist/ -python3 -m build -python3 -m twine check dist/* -``` - -## GitHub Release Automation - -Use the `Release` workflow from GitHub Actions after the local checks pass. -The default `dry_run=true` mode builds the wheel and sdist, runs -`twine check dist/*`, and uploads `dist/*` as workflow artifacts without -creating a GitHub release or publishing to a package index. - -When creating a release, update the project version first, then dispatch the -workflow with `dry_run=false`, `create_github_release=true`, and -`tag_name=vX.Y.Z`. The workflow creates a GitHub release with generated notes -and attaches the built wheel and sdist. Keep `draft=true` for the first run, -review the generated notes and assets on GitHub, then publish the draft -manually. - -Draft GitHub release assets use temporary `untagged-*` download URLs. Publish -the draft before using release asset URLs in the Flathub manifest or other -public metadata. - -Generated GitHub release notes can be sparse for direct release commits. Review -and edit the draft notes before publishing the GitHub release. - -Set `publish_testpypi=true` only after the `testpypi` GitHub environment and -TestPyPI trusted publisher are configured. The TestPyPI job uses -`pypa/gh-action-pypi-publish` with OIDC and does not use API tokens. Keep PyPI -publishing separate until TestPyPI installs have been validated. For a -TestPyPI-only validation run, use `dry_run=false`, -`create_github_release=false`, and `publish_testpypi=true`. +For PipeWire routing, analyzer capture, or filter-chain runtime changes, also +run the app interactively with real music before merging the PR to `main` or +publishing a release. Exercise enable/disable, output switching, preset +changes, analyzer display, shutdown, and stream restoration against the actual +desktop audio graph. -Set `publish_pypi=true` only after the `pypi` GitHub environment and PyPI -trusted publisher are configured. The PyPI job also uses -`pypa/gh-action-pypi-publish` with OIDC and does not use API tokens. Keep the -`pypi` environment protected with required review before publishing production -packages. - -Keep owner-specific workflow dispatch commands, environment approvals, and -local release sequencing in an ignored repo-local skill rather than in public -documentation. - -## Security - -Before publishing release artifacts, run the full preflight. Its focused leak -scan covers `HEAD`, tracked worktree changes, and untracked non-ignored text -files, so run it again after any release-process edits: +Before the manual real-music pass, run the opt-in live UI smoke. It starts a +private PipeWire/WirePlumber graph, synthetic playback, nested headless GNOME +Shell, the real Mini EQ GTK process, and AT-SPI UI controls: ```bash -python3 tools/release_preflight.py +.venv/bin/python tools/check_live_ui_runtime.py --timeout 35 --cycles 1 +MINI_EQ_RUN_LIVE_UI=1 .venv/bin/python -m pytest tests/test_mini_eq_live_ui_runtime.py -q ``` -If reproducing the scan manually, check committed history, the tracked worktree, -and untracked non-ignored files. Do not rely on a `HEAD`-only scan before -committing release files: +There is an optional hosted Flatpak runtime smoke path in the `CI` workflow. +Use it as extra signal or for smoke-harness work; keep the local runtime smoke +as the release check when app/runtime routing behavior changed. -```bash -git rev-list --count HEAD -git ls-remote --heads origin -git ls-remote --tags origin -leak_pattern='(/home/|/Users/|secret|token|api[_-]?key|github_pat|gh[pousr]_[A-Za-z0-9_]{20,}|pypi-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|BEGIN [A-Z0-9 ]*PRIVATE KEY)' -git grep -n -I -E "$leak_pattern" HEAD -- . \ - ':(exclude)*.png' \ - ':(exclude)AGENTS.md' \ - ':(exclude)docs/release.md' \ - ':(exclude)tools/release_preflight.py' -git grep -n -I -E "$leak_pattern" -- . \ - ':(exclude)*.png' \ - ':(exclude)AGENTS.md' \ - ':(exclude)docs/release.md' \ - ':(exclude)tools/release_preflight.py' -git ls-files --others --exclude-standard -z -- . \ - | grep -z -v -E '(^|/)(AGENTS\.md|docs/release\.md|tools/release_preflight\.py)$|\.png$' \ - | xargs -0 -r grep -n -I -E "$leak_pattern" -- -``` +## Package Channels -This focused grep does not replace GitHub secret scanning, push protection, or -a dedicated scanner such as Gitleaks when deeper local investigation is needed: +Use the `Release` workflow from GitHub Actions after local checks pass. Keep +`dry_run=true` for packaging workflow changes. For real releases, keep the +GitHub release as a draft first, review generated notes and assets, and publish +the draft only after package-index checks pass. -```bash -gitleaks git --no-banner --redact . -``` +Use Trusted Publishing/OIDC for TestPyPI and PyPI. Do not use long-lived PyPI +API tokens. Keep the `pypi` environment protected with required review before +production publishing. -Keep these GitHub security features enabled in Settings > Advanced Security: - -- Dependency graph -- Dependabot alerts -- Dependabot security updates -- Dependabot version updates -- Secret scanning -- Push protection -- Private vulnerability reporting -- CodeQL code scanning - -Then protect `main` with a branch protection rule or ruleset that requires the -`test`, `wireplumber-0-4-compat`, `flatpak`, and `dependency-review` status -checks before merging. - -## Test The Wheel - -Use `--system-site-packages` so the test environment can see distro-provided GI bindings. +Install-check TestPyPI artifacts with PyPI enabled for dependencies and pin the +exact version being validated: ```bash -python3 -m venv --system-site-packages /tmp/mini-eq-wheel-test -/tmp/mini-eq-wheel-test/bin/python -m pip install --upgrade pip -/tmp/mini-eq-wheel-test/bin/python -m pip install dist/*.whl -/tmp/mini-eq-wheel-test/bin/mini-eq --check-deps -/tmp/mini-eq-wheel-test/bin/mini-eq --help +python3 -m venv --system-site-packages /tmp/mini-eq-testpypi +/tmp/mini-eq-testpypi/bin/python -m pip install --upgrade pip +/tmp/mini-eq-testpypi/bin/python -m pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "mini-eq==$version" +/tmp/mini-eq-testpypi/bin/mini-eq --check-deps ``` -## Publish - -For GitHub releases, dispatch the `Release` workflow after the local checks -above pass. Verify that the README, package URLs, issue tracker, license, and -screenshots render correctly without a logged-in GitHub session. +Do not add PyGObject as a Mini EQ PyPI dependency. Keep it a distro/runtime +requirement (`python3-gi`, `python3-gobject`, etc.) so it matches the host +GObject-Introspection and GTK stack. -Validate package-index publishing on TestPyPI before enabling PyPI publishing. -For production PyPI publishing, dispatch the workflow with `dry_run=false`, -`create_github_release=true`, `tag_name=vX.Y.Z`, and `publish_pypi=true`. -Use Trusted Publishing/OIDC and the separate `pypi` environment rather than -long-lived API tokens. - -After the workflow completes: - -```bash -gh release view vX.Y.Z --repo bhack/mini-eq --json tagName,isDraft,isPrerelease,assets,url -curl -fsSL https://pypi.org/pypi/mini-eq/json | jq -r '.info.version' -git ls-remote --tags origin vX.Y.Z -``` +## Post-Publish -Publish the draft GitHub release after reviewing its notes and assets. Fetch -tags after publishing if the workflow created the release tag remotely: +After publishing the GitHub release: ```bash +gh release view "$tag" --repo bhack/mini-eq --json tagName,isDraft,isPrerelease,assets,url git fetch --tags origin +curl -fsSL https://pypi.org/pypi/mini-eq/json | jq -r '.info.version' +git ls-remote --tags origin "$tag" +python3 tools/release_post_publish.py "$version" ``` -Then run the post-publish verifier: - -```bash -python3 tools/release_post_publish.py X.Y.Z -``` - -This confirms that the GitHub release is no longer a draft, asset URLs use the -stable `vX.Y.Z` tag instead of temporary `untagged-*` draft URLs, the remote tag -exists, PyPI can see the version, and the downloaded source archive SHA-256 -matches the GitHub release asset digest. Use the printed source archive SHA-256 -for the Flathub repository update. +`tools/release_post_publish.py` verifies that the GitHub release is no longer a +draft, asset URLs use the stable tag instead of temporary `untagged-*` draft +URLs, the remote tag exists, PyPI can see the version, and the downloaded source +archive SHA-256 matches the GitHub release asset digest. Use the printed source +archive SHA-256 for the Flathub repository update. ## Flathub Handoff Keep the detailed Flathub packaging procedure in `docs/flathub.md` and in the -Flathub packaging repository. From this upstream repository, the public release -handoff is: +Flathub packaging repository. From this upstream repository, the release handoff +is maintainer-owned: 1. Confirm the GitHub release is published, not draft. 2. Compute or verify the release source archive SHA-256. -3. Update the sibling Flathub repository manifest to the published release URL +3. Update the Flathub packaging repository manifest to the published release URL and SHA-256. 4. Run Flathub manifest lint and a download-only build in the Flathub repository. -5. Compare the two manifests for unintended drift: +5. Compare the two manifests and synced dependency files for unintended drift: ```bash - python3 tools/check_flathub_manifest_drift.py + python3 tools/check_flathub_manifest_drift.py \ + io.github.bhack.mini-eq.yaml \ + /path/to/flathub/io.github.bhack.mini-eq.yaml ``` -6. Open a Flathub PR against `flathub/io.github.bhack.mini-eq`. +6. As the maintainer, open a Flathub PR against + `flathub/io.github.bhack.mini-eq`. 7. Wait for the PR status to reach `success / Build ready` before merging. A temporary `pending / Committing build...` status after the build pipeline succeeds is normal while Flathub commits the test build. +8. Install and run the temporary Flathub PR build when runtime behavior changed. + +Use the Flathub `beta` branch only when a release candidate needs broader +Flatpak testing before the stable update: + +```bash +flatpak remote-add --if-not-exists flathub-beta https://flathub.org/beta-repo/flathub-beta.flatpakrepo +flatpak install flathub-beta io.github.bhack.mini-eq +flatpak run --branch=beta io.github.bhack.mini-eq +``` + +## Security + +The release preflight includes a focused privacy and credential scan across +`HEAD`, tracked worktree changes, and untracked non-ignored text files. It does +not replace GitHub secret scanning, push protection, or a deeper local scanner +when release history or generated artifacts look suspicious: + +```bash +gitleaks git --no-banner --redact . +``` + +Keep these GitHub security features enabled in Settings > Advanced Security: + +- Dependency graph +- Dependabot alerts +- Dependabot security updates +- Dependabot version updates +- Secret scanning +- Push protection +- Private vulnerability reporting +- CodeQL code scanning + +Protect `main` with a branch protection rule or ruleset that requires the +`test`, `tooling`, `flatpak`, `dependency-review`, and CodeQL status checks +before merging. diff --git a/flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch b/flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch deleted file mode 100644 index 8537ce6..0000000 --- a/flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch +++ /dev/null @@ -1,23 +0,0 @@ -diff --git a/po/meson.build b/po/meson.build -index 2ad4f42..8a62b1f 100644 ---- a/po/meson.build -+++ b/po/meson.build -@@ -7,4 +7,4 @@ --if python_po.found() and spa_json_dump_po.found() -+if get_option('tools') and python_po.found() and spa_json_dump_po.found() - conf_pot = custom_target('conf.pot', - input : i18n_conf, - output : 'conf.pot', -diff --git a/meson.build b/meson.build -index f118b37..d1811e8 100644 ---- a/meson.build -+++ b/meson.build -@@ -182,5 +182,7 @@ if build_modules - subdir('modules') - endif - subdir('src') --subdir('po') -+if build_daemon or build_tools -+ subdir('po') -+endif - subdir('docs') diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml index 7d7e6a0..4b8b22b 100644 --- a/io.github.bhack.mini-eq.yaml +++ b/io.github.bhack.mini-eq.yaml @@ -117,27 +117,15 @@ modules: tag: 1.6.1 commit: b7341d068947225fcdf62d39277606e8516d7f52 - - name: wireplumber + - name: pipewire-gobject buildsystem: meson config-opts: - - -Dintrospection=enabled - - -Ddoc=disabled - - -Dmodules=false - - -Ddaemon=false - - -Dtools=false - - -Dtests=false - - -Ddbus-tests=false - - -Dsystemd=disabled - - -Dsystemd-system-service=false - - -Dsystemd-user-service=false - - -Delogind=disabled + - -Dwheel=true sources: - type: git - url: https://gitlab.freedesktop.org/pipewire/wireplumber.git - tag: 0.5.14 - commit: 07e730b279ac7a520699ae9f6b0797848a731b30 - - type: patch - path: flatpak/patches/wireplumber-0.5.14-tools-disabled-po.patch + url: https://github.com/bhack/pipewire-gobject.git + tag: 0.3.4 + commit: 82658f10338c2e9530ae575db30f15e709626cdc - python3-dependencies.yaml diff --git a/pyproject.toml b/pyproject.toml index 30e3f9c..933f5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,13 +19,12 @@ keywords = [ "filter-chain", "flatpak", "gtk", - "jack", "libadwaita", "linux", "parametric-eq", + "pipewire-gobject", "pipewire", "spectrum-analyzer", - "wireplumber", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -41,7 +40,7 @@ classifiers = [ "Topic :: Multimedia :: Sound/Audio :: Analysis", "Topic :: Multimedia :: Sound/Audio :: Mixers", ] -dependencies = ["JACK-Client>=0.5.5", "numpy>=1.26"] +dependencies = ["numpy>=1.26", "pipewire-gobject>=0.3.4,<0.4"] [project.urls] Homepage = "https://github.com/bhack/mini-eq" diff --git a/python3-dependencies.yaml b/python3-dependencies.yaml index 3b41f28..bbb22a1 100644 --- a/python3-dependencies.yaml +++ b/python3-dependencies.yaml @@ -1,30 +1,8 @@ -# Generated with flatpak-pip-generator --runtime org.gnome.Sdk//50 --yaml --output python3-dependencies --prefer-wheels=numpy,cffi JACK-Client==0.5.5 numpy==2.4.4 +# Generated with flatpak-pip-generator --runtime org.gnome.Sdk//50 --yaml --output python3-dependencies --prefer-wheels=numpy numpy==2.4.4 name: python3-dependencies buildsystem: simple build-commands: [] modules: - - name: python3-JACK-Client - buildsystem: simple - build-commands: - - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" - --prefix=${FLATPAK_DEST} "JACK-Client==0.5.5" --no-build-isolation - sources: - - type: file - url: https://files.pythonhosted.org/packages/1d/82/95c5fff8c902eb7cb7e823a43987166b428cdbf94533c66078581f7461a1/JACK_Client-0.5.5-py3-none-any.whl - sha256: f6adb6c9f1473ce3c37505cacc93a99d215b90bf1b81cb4de7ba10767d2618b8 - - type: file - url: https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - sha256: c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 - only-arches: - - x86_64 - - type: file - url: https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl - sha256: d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b - only-arches: - - aarch64 - - type: file - url: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl - sha256: b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 - name: python3-numpy buildsystem: simple build-commands: diff --git a/src/mini_eq/analyzer.py b/src/mini_eq/analyzer.py index 8e272f7..d2760a1 100644 --- a/src/mini_eq/analyzer.py +++ b/src/mini_eq/analyzer.py @@ -1,7 +1,6 @@ from __future__ import annotations import math -import os import sys import threading import time @@ -57,12 +56,24 @@ ANALYZER_DISPLAY_GAIN_MIN = -12.0 ANALYZER_DISPLAY_GAIN_MAX = 32.0 ANALYZER_DISPLAY_GAIN_DEFAULT = 0.0 -JACK_CLIENT_NAME = "mini-eq-analyzer" -JACK_LEFT_INPUT_PORT = "input_FL" -JACK_RIGHT_INPUT_PORT = "input_FR" -JACK_CAPTURE_QUEUE_BLOCKS = 128 -JACK_PIPEWIRE_PROPS = ( - "node.autoconnect = false node.dont-move = true stream.monitor = true media.category = Monitor media.role = DSP" +ANALYZER_CAPTURE_QUEUE_BLOCKS = 128 +ANALYZER_NODE_NAME = "mini-eq-analyzer" +ANALYZER_NODE_DESCRIPTION = "Mini EQ Monitor" +ANALYZER_APPLICATION_ID = "io.github.bhack.mini-eq" +ANALYZER_MEDIA_CLASS = "Stream/Input/Audio/Internal" +ANALYZER_PIPEWIRE_PROPERTIES = ( + ("node.name", ANALYZER_NODE_NAME), + ("node.description", ANALYZER_NODE_DESCRIPTION), + ("application.name", "Mini EQ"), + ("application.id", ANALYZER_APPLICATION_ID), + ("media.name", ANALYZER_NODE_DESCRIPTION), + ("media.class", ANALYZER_MEDIA_CLASS), + ("media.category", "Monitor"), + ("media.role", "DSP"), + ("node.dont-move", "true"), + ("stream.monitor", "true"), + ("state.restore-props", "false"), + ("state.restore-target", "false"), ) ANALYZER_RESPONSE_MIN = 0.02 ANALYZER_RESPONSE_MAX = 15.0 @@ -232,6 +243,24 @@ def stereo_f32le_bytes_to_interleaved_float32(left_payload: bytes, right_payload return interleaved +def interleaved_f32le_bytes_to_channel_payloads(payload: bytes, channels: int) -> tuple[bytes, bytes]: + channel_count = max(1, int(channels)) + frame_size = ANALYZER_SAMPLE_WIDTH_BYTES * channel_count + usable_size = len(payload) - (len(payload) % frame_size) + if usable_size == 0: + return b"", b"" + + if channel_count == 1: + mono_payload = payload[:usable_size] + return mono_payload, mono_payload + + np = require_numpy() + frames = np.frombuffer(payload[:usable_size], dtype=" str: - return str(getattr(port, "name", port)) - - -def jack_port_short_name(port) -> str: - return str(getattr(port, "shortname", jack_port_name(port).rsplit(":", 1)[-1])) - - -def jack_port_client_name(port) -> str: - return jack_port_name(port).rsplit(":", 1)[0] - - -def jack_sink_name_candidates(sink_name: str, sink_description: str | None = None) -> tuple[str, ...]: - candidates: list[str] = [] - - for candidate in (sink_description, sink_name): - if candidate and candidate not in candidates: - candidates.append(candidate) - - return tuple(candidates) - - -def jack_port_client_matches_sink(port, sink_candidates: set[str]) -> bool: - client_name = jack_port_client_name(port) - return any(client_name == candidate or client_name.startswith(f"{candidate}-") for candidate in sink_candidates) - - -def jack_pipewire_props(existing_props: str | None = None) -> str: - if existing_props and not existing_props.lstrip().startswith("{"): - return f"{existing_props} {JACK_PIPEWIRE_PROPS}" - - return JACK_PIPEWIRE_PROPS - - -def jack_audio_output_ports_for_sink( - ports: list, - sink_name: str, - sink_description: str | None = None, -) -> list: - candidates = set(jack_sink_name_candidates(sink_name, sink_description)) - return [port for port in ports if jack_port_client_matches_sink(port, candidates)] - - -def select_jack_stereo_output_ports(ports: list) -> tuple[object | None, object | None]: - left_suffixes = ("monitor_FL", "monitor_AUX0") - right_suffixes = ("monitor_FR", "monitor_AUX1") - mono_suffixes = ("monitor_MONO",) - - def find_by_suffix(suffixes: tuple[str, ...]): - for suffix in suffixes: - for port in ports: - if jack_port_short_name(port) == suffix or jack_port_short_name(port).endswith(f"_{suffix}"): - return port - return None - - left = find_by_suffix(left_suffixes) - right = find_by_suffix(right_suffixes) - mono = find_by_suffix(mono_suffixes) - - if left is None and right is None and mono is not None: - return mono, mono - - if left is None and right is not None: - left = right - if right is None and left is not None: - right = left - - return left, right - - -def disconnect_jack_input_port_connections(client, input_ports: tuple[object | None, ...]) -> None: - for input_port in input_ports: - if input_port is None: - continue - - try: - connections = client.get_all_connections(input_port) - except Exception: - continue - - for source_port in connections: - try: - client.disconnect(source_port, input_port) - except Exception: - pass - - class OutputSpectrumAnalyzer: def __init__( self, @@ -483,17 +425,20 @@ def __init__( self.loudness_callback = loudness_callback self.status_callback = status_callback self.enabled = False - self.client = None - self.client_active = False - self.left_input_port = None - self.right_input_port = None + self.stream = None + self.stream_active = False + self.stream_signal_handler_ids: list[int] = [] self.reader_thread: threading.Thread | None = None self.stop_event = threading.Event() self.stop_event.set() - self.audio_blocks = deque(maxlen=JACK_CAPTURE_QUEUE_BLOCKS) + self.audio_blocks = deque(maxlen=ANALYZER_CAPTURE_QUEUE_BLOCKS) self.sample_rate = SAMPLE_RATE self.response_speed = ANALYZER_RESPONSE_DEFAULT + @property + def client(self): + return self.stream + def set_levels_callback(self, callback: Callable[[list[float]], None] | None) -> None: self.levels_callback = callback @@ -509,18 +454,13 @@ def set_output_sink_name(self, sink_name: str, sink_description: str | None = No self.output_sink_name = sink_name self.output_sink_description = sink_description - if not self.enabled: - return + if self.stream is not None: + self.stop(close_stream=True) - if self.client is None: - self.restart() + if not self.enabled: return - try: - disconnect_jack_input_port_connections(self.client, (self.left_input_port, self.right_input_port)) - self.connect_jack_monitor_ports(self.client) - except Exception as exc: - self.status_callback(f"analyzer output reconnect failed: {exc}") + self.restart() def set_enabled(self, enabled: bool) -> bool: self.enabled = bool(enabled) @@ -539,31 +479,34 @@ def start(self) -> bool: self.stop_event.clear() try: - if self.client is None: - self.client = self.open_jack_client() - self.activate_jack_client(self.client) + if self.stream is None: + self.stream = self.open_pwg_stream() + self.activate_pwg_stream(self.stream) except Exception as exc: self.stop_event.set() self.status_callback(f"Analyzer Unavailable: {exc}") return False - self.reader_thread = threading.Thread(target=self.read_jack_levels, name="mini-eq-analyzer", daemon=True) + self.reader_thread = threading.Thread(target=self.read_audio_levels, name="mini-eq-analyzer", daemon=True) self.reader_thread.start() return True def prepare(self) -> bool: - if self.client is not None: + if self.stream is not None: return True try: - self.client = self.open_jack_client() + self.stream = self.open_pwg_stream() except Exception: return False return True - def stop(self, *, close_client: bool = False) -> None: - client = self.client + def stop(self, *, close_stream: bool = False, close_client: bool | None = None) -> None: + if close_client is not None: + close_stream = close_client + + stream = self.stream reader_thread = self.reader_thread self.reader_thread = None @@ -571,100 +514,78 @@ def stop(self, *, close_client: bool = False) -> None: self.audio_blocks.clear() self.emit_loudness_snapshot(None) - if client is not None and self.client_active and not close_client: - self.deactivate_jack_client(client) + if stream is not None and self.stream_active: + self.deactivate_pwg_stream(stream) if reader_thread is not None and reader_thread is not threading.current_thread(): reader_thread.join(timeout=ANALYZER_READER_JOIN_TIMEOUT_SECONDS) - if close_client and client is not None: - self.close_jack_client(client) - self.client = None - self.left_input_port = None - self.right_input_port = None + if close_stream and stream is not None: + self.close_pwg_stream(stream) + self.stream = None def close(self) -> None: - self.stop(close_client=True) + self.stop(close_stream=True) def restart(self) -> bool: - self.stop(close_client=True) + self.stop(close_stream=True) if not self.enabled: return True return self.start() - def open_jack_client(self): - try: - import jack - except Exception as exc: - raise RuntimeError("Python JACK client is not available") from exc - - old_pipewire_props = os.environ.get("PIPEWIRE_PROPS") - os.environ["PIPEWIRE_PROPS"] = jack_pipewire_props(old_pipewire_props) - try: - client = jack.Client(JACK_CLIENT_NAME, no_start_server=True) - finally: - if old_pipewire_props is None: - os.environ.pop("PIPEWIRE_PROPS", None) - else: - os.environ["PIPEWIRE_PROPS"] = old_pipewire_props - - self.sample_rate = float(client.samplerate or SAMPLE_RATE) - return client - - def activate_jack_client(self, client) -> None: - if self.client_active: - disconnect_jack_input_port_connections(client, (self.left_input_port, self.right_input_port)) - self.connect_jack_monitor_ports(client) + def open_pwg_stream(self): + _GLib, Pwg = self._import_pipewire_gobject() + Pwg.init() + stream = Pwg.Stream.new_audio_capture(self.output_sink_name, True) + set_pipewire_property = getattr(stream, "set_pipewire_property", None) + if callable(set_pipewire_property): + for key, value in ANALYZER_PIPEWIRE_PROPERTIES: + set_pipewire_property(key, value) + stream.set_requested_format("F32", int(SAMPLE_RATE), 2) + stream.set_deliver_audio_blocks(True) + handler_id = stream.connect("audio-block", self.process_audio_block) + self.stream_signal_handler_ids = [handler_id] + self.sample_rate = float(stream.get_requested_rate() or SAMPLE_RATE) + return stream + + def activate_pwg_stream(self, stream) -> None: + if self.stream_active: return - if self.left_input_port is None: - self.left_input_port = client.inports.register(JACK_LEFT_INPUT_PORT, is_terminal=True) - if self.right_input_port is None: - self.right_input_port = client.inports.register(JACK_RIGHT_INPUT_PORT, is_terminal=True) - try: - client.set_process_callback(self.process_jack_audio) - client.activate() - self.client_active = True - disconnect_jack_input_port_connections(client, (self.left_input_port, self.right_input_port)) - self.connect_jack_monitor_ports(client) + if not stream.start(): + raise RuntimeError("PipeWire analyzer stream failed to start") + self.stream_active = True + self.sample_rate = float(stream.get_rate() or stream.get_requested_rate() or SAMPLE_RATE) except Exception: - self.deactivate_jack_client(client) + self.deactivate_pwg_stream(stream) raise - def deactivate_jack_client(self, client) -> None: + def deactivate_pwg_stream(self, stream) -> None: try: - client.deactivate() + stream.stop() except Exception: pass - self.client_active = False + self.stream_active = False - def close_jack_client(self, client) -> None: - if self.client_active: - self.deactivate_jack_client(client) + def close_pwg_stream(self, stream) -> None: + if self.stream_active: + self.deactivate_pwg_stream(stream) + + for handler_id in self.stream_signal_handler_ids: + try: + stream.disconnect(handler_id) + except Exception: + pass + self.stream_signal_handler_ids = [] try: - client.close() + stream.stop() except Exception: pass - def connect_jack_monitor_ports(self, client) -> None: - output_ports = jack_audio_output_ports_for_sink( - client.get_ports(is_audio=True, is_output=True), - self.output_sink_name, - self.output_sink_description, - ) - left_output_port, right_output_port = select_jack_stereo_output_ports(output_ports) - - if left_output_port is None or right_output_port is None: - sink_label = self.output_sink_description or self.output_sink_name - raise RuntimeError(f"JACK monitor ports not found for {sink_label}") - - client.connect(jack_port_name(left_output_port), jack_port_name(self.left_input_port)) - client.connect(jack_port_name(right_output_port), jack_port_name(self.right_input_port)) - def create_loudness_meter(self): try: from .ebur128 import EBUR128_MODE_I, EBUR128_MODE_S, Ebur128Meter @@ -707,18 +628,20 @@ def close_loudness_meter(self, meter) -> None: except Exception: pass - def process_jack_audio(self, _frames: int) -> None: - if self.stop_event.is_set() or self.left_input_port is None or self.right_input_port is None: + def process_audio_block(self, _stream, block) -> None: + if self.stop_event.is_set(): return - self.audio_blocks.append( - ( - bytes(self.left_input_port.get_buffer()), - bytes(self.right_input_port.get_buffer()), - ) - ) + audio_format = block.get_format() + sample_format = audio_format.get_sample_format() + if sample_format != "F32": + return - def read_jack_levels(self) -> None: + self.sample_rate = float(audio_format.get_rate() or self.sample_rate or SAMPLE_RATE) + data = block.get_data().get_data() + self.audio_blocks.append(interleaved_f32le_bytes_to_channel_payloads(data, audio_format.get_channels())) + + def read_audio_levels(self) -> None: pending_samples = array("f") fft_samples = array("f") fft_size = analyzer_fft_size(self.sample_rate) @@ -786,3 +709,26 @@ def read_jack_levels(self) -> None: self.close_loudness_meter(loudness_meter) if self.reader_thread is threading.current_thread(): self.reader_thread = None + + @staticmethod + def _import_pipewire_gobject(): + shim_error: Exception | None = None + try: + import pipewire_gobject # noqa: F401 + except Exception as exc: # pragma: no cover - depends on installed packaging layout + shim_error = exc + + try: + import gi + + gi.require_version("Pwg", "0.1") + from gi.repository import GLib, Pwg + except Exception as exc: + if shim_error is not None: + raise RuntimeError( + f"pipewire-gobject is not available: Python shim failed with {shim_error}; " + f"Pwg GI import failed with {exc}" + ) from exc + raise + + return GLib, Pwg diff --git a/src/mini_eq/app.py b/src/mini_eq/app.py index b41a922..6e3c722 100644 --- a/src/mini_eq/app.py +++ b/src/mini_eq/app.py @@ -7,6 +7,7 @@ import gi gi.require_version("Adw", "1") +gi.require_version("GLibUnix", "2.0") from gi.repository import Adw, Gio, GLib, GLibUnix diff --git a/src/mini_eq/cli.py b/src/mini_eq/cli.py index 0c3ec6f..6a871d6 100644 --- a/src/mini_eq/cli.py +++ b/src/mini_eq/cli.py @@ -5,7 +5,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( - description="System-wide parametric EQ using GTK, WirePlumber routing and PipeWire filter-chain.", + description="System-wide parametric EQ using GTK, pipewire-gobject and PipeWire filter-chain.", ) parser.add_argument( "--install-desktop", diff --git a/src/mini_eq/dbus_control.py b/src/mini_eq/dbus_control.py index 5f885ee..66f2861 100644 --- a/src/mini_eq/dbus_control.py +++ b/src/mini_eq/dbus_control.py @@ -7,7 +7,6 @@ from . import __version__ from .analyzer import analyzer_level_to_display_norm from .core import list_preset_names, sanitize_preset_name -from .window_utils import set_switch_confirmed_state BUS_NAME = "io.github.bhack.mini-eq" OBJECT_PATH = "/io/github/bhack/mini_eq/Control" @@ -68,21 +67,13 @@ def set_eq_enabled(self, enabled: bool) -> None: ... def route_system_audio(self, enabled: bool) -> None: ... -class SwitchProtocol(Protocol): - def set_active(self, active: bool) -> None: ... - def set_state(self, state: bool) -> None: ... - - class WindowProtocol(Protocol): current_preset_name: str | None ui_shutting_down: bool - updating_ui: bool analyzer_enabled: bool analyzer_levels: list[float] analyzer_display_gain_db: float controller: ControllerProtocol - bypass_switch: SwitchProtocol - route_switch: SwitchProtocol def load_library_preset(self, name: str) -> None: ... @@ -90,21 +81,22 @@ def present(self) -> None: ... def get_visible(self) -> bool: ... - def sync_ui_from_state(self) -> None: ... - - def update_eq_power_indicator(self) -> None: ... - - def update_info_label(self) -> None: ... + def sync_control_switches_from_controller(self, *, route: bool = True, eq: bool = True) -> None: ... - def update_status_summary(self) -> None: ... - - def update_focus_summary(self) -> None: ... - - def invalidate_graph_response_cache(self) -> None: ... - - def queue_graph_draw(self) -> None: ... + def refresh_after_route_state_changed( + self, + *, + eq_was_enabled: bool, + announce_enabled: bool | None = None, + notify: bool = True, + ) -> None: ... - def update_preset_state(self) -> None: ... + def refresh_after_eq_state_changed( + self, + *, + announce_enabled: bool | None = None, + notify: bool = True, + ) -> None: ... def output_preset_link_name(self) -> str | None: ... @@ -200,38 +192,57 @@ def list_presets(self) -> list[str]: def analyzer_levels(self) -> list[float]: return panel_analyzer_levels(self.app.window) - def emit_state_changed(self) -> None: - if self.connection is None: + def _connection_is_closed(self, connection: Gio.DBusConnection) -> bool: + is_closed = getattr(connection, "is_closed", None) + if is_closed is None: + return False + + return bool(is_closed()) + + def _drop_closed_connection(self, connection: Gio.DBusConnection) -> None: + if self.connection is connection: + self.registration_id = 0 + self.connection = None + + def _is_closed_connection_error(self, exc: GLib.GError) -> bool: + return bool(exc.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CLOSED)) + + def _emit_signal(self, signal_name: str, parameters: GLib.Variant | None) -> None: + connection = self.connection + if connection is None: + return + if self._connection_is_closed(connection): + self._drop_closed_connection(connection) return - self.connection.emit_signal( - None, - OBJECT_PATH, - INTERFACE_NAME, + try: + connection.emit_signal( + None, + OBJECT_PATH, + INTERFACE_NAME, + signal_name, + parameters, + ) + except GLib.GError as exc: + if self._is_closed_connection_error(exc) or self._connection_is_closed(connection): + self._drop_closed_connection(connection) + return + raise + + def emit_state_changed(self) -> None: + self._emit_signal( "StateChanged", GLib.Variant("(a{sv})", (self.state(),)), ) def emit_analyzer_levels_changed(self) -> None: - if self.connection is None: - return - - self.connection.emit_signal( - None, - OBJECT_PATH, - INTERFACE_NAME, + self._emit_signal( "AnalyzerLevelsChanged", GLib.Variant("(ad)", (self.analyzer_levels(),)), ) def emit_presets_changed(self) -> None: - if self.connection is None: - return - - self.connection.emit_signal( - None, - OBJECT_PATH, - INTERFACE_NAME, + self._emit_signal( "PresetsChanged", None, ) @@ -247,18 +258,7 @@ def set_eq_enabled(self, enabled: bool) -> None: controller.set_eq_enabled(enabled) if window is not None and not window.ui_shutting_down: - window.updating_ui = True - try: - set_switch_confirmed_state(window.bypass_switch, enabled) - finally: - window.updating_ui = False - - window.update_eq_power_indicator() - window.update_info_label() - window.update_status_summary() - window.invalidate_graph_response_cache() - window.queue_graph_draw() - window.update_preset_state() + window.refresh_after_eq_state_changed(notify=False) self.emit_state_changed() @@ -276,29 +276,11 @@ def set_routing_enabled(self, enabled: bool) -> None: controller.route_system_audio(enabled) except Exception: if window is not None and not window.ui_shutting_down: - window.updating_ui = True - try: - set_switch_confirmed_state(window.bypass_switch, controller.eq_enabled) - set_switch_confirmed_state(window.route_switch, controller.routed) - finally: - window.updating_ui = False + window.sync_control_switches_from_controller() raise if window is not None and not window.ui_shutting_down: - window.updating_ui = True - try: - set_switch_confirmed_state(window.bypass_switch, controller.eq_enabled) - set_switch_confirmed_state(window.route_switch, controller.routed) - finally: - window.updating_ui = False - window.update_eq_power_indicator() - window.update_info_label() - window.update_status_summary() - window.update_focus_summary() - if not eq_was_enabled and controller.eq_enabled: - window.invalidate_graph_response_cache() - window.queue_graph_draw() - window.update_preset_state() + window.refresh_after_route_state_changed(eq_was_enabled=eq_was_enabled, notify=False) self.emit_state_changed() diff --git a/src/mini_eq/deps.py b/src/mini_eq/deps.py index 2476291..6ec15c8 100644 --- a/src/mini_eq/deps.py +++ b/src/mini_eq/deps.py @@ -12,13 +12,21 @@ Status = Literal["ok", "missing", "warning"] +PWG_REQUIRED_VERSION = "0.3.4" +PWG_REQUIRED_VERSION_PARTS = (0, 3, 4) +PWG_REQUIRED_SYMBOLS = ( + "Core.set_pipewire_property", + "Param.new_props_controls", + "Stream.set_pipewire_property", +) PYGOBJECT_HINT = "Ubuntu/Debian: python3-gi; Fedora: python3-gobject; Arch: python-gobject" PYCAIRO_HINT = "Ubuntu/Debian: python3-cairo; Fedora: python3-cairo; Arch: python-cairo" GTK_HINT = "Ubuntu/Debian: gir1.2-gtk-4.0; Fedora: gtk4; Arch: gtk4. Requires GTK 4.12+." ADW_HINT = "Ubuntu/Debian: gir1.2-adw-1; Fedora: libadwaita; Arch: libadwaita. Requires Libadwaita 1.7+." -WIREPLUMBER_GI_VERSIONS = ("0.5", "0.4") - -WP_HINT = "Ubuntu 24.04: gir1.2-wp-0.4 wireplumber; newer Debian/Ubuntu: gir1.2-wp-0.5 wireplumber; Fedora: wireplumber wireplumber-libs; Arch: wireplumber libwireplumber" +PWG_HINT = ( + "Install pipewire-gobject from PyPI or your distribution. " + "It also needs system libpipewire-0.3, GLib, GObject, GIO, and PyGObject." +) PIPEWIRE_HINT = ( "Ubuntu/Debian: pipewire pipewire-bin wireplumber; Fedora: pipewire wireplumber; Arch: pipewire wireplumber" ) @@ -26,10 +34,6 @@ "Ubuntu/Debian: pipewire; Fedora: pipewire; Arch: pipewire. " "Flatpak builds bundle only the filter-chain module and SPA builtin filter support." ) -JACK_HINT = ( - "Install the Python JACK client and PipeWire JACK support. " - "For pip environments: python -m pip install JACK-Client; system packages must provide libjack." -) NUMPY_HINT = ( "Install the package with Python dependencies: python -m pip install mini-eq, or python -m pip install numpy." ) @@ -155,6 +159,78 @@ def check_first_available_gi_repository( return DependencyCheck(label, "missing", required, "; ".join(failures), hint) +def parse_dotted_version(value: str) -> tuple[int, int, int]: + parts: list[int] = [] + for raw_part in value.split("."): + digits = "" + for char in raw_part: + if not char.isdigit(): + break + digits += char + if not digits: + break + parts.append(int(digits)) + + while len(parts) < 3: + parts.append(0) + + return tuple(parts[:3]) + + +def check_pipewire_gobject() -> DependencyCheck: + shim_check = check_python_import("pipewire_gobject", "pipewire-gobject Python shim", True, PWG_HINT) + namespace_check = check_gi_repository("Pwg", "0.1", "pipewire-gobject Pwg GI namespace", True, PWG_HINT) + + if not shim_check.ok or not namespace_check.ok: + detail = f"Python shim: {shim_check.detail}; Pwg GI: {namespace_check.detail}" + return DependencyCheck("pipewire-gobject", "missing", True, detail, PWG_HINT) + + module = importlib.import_module("gi.repository.Pwg") + missing_symbols: list[str] = [] + for symbol in PWG_REQUIRED_SYMBOLS: + current = module + checked_path = "Pwg" + for path_part in symbol.split("."): + checked_path = f"{checked_path}.{path_part}" + if not hasattr(current, path_part): + missing_symbols.append(checked_path) + break + current = getattr(current, path_part) + + try: + actual_version = str(module.get_library_version()) + except Exception as exc: + return DependencyCheck( + "pipewire-gobject", "missing", True, f"could not read Pwg library version: {exc}", PWG_HINT + ) + + if parse_dotted_version(actual_version) < PWG_REQUIRED_VERSION_PARTS: + return DependencyCheck( + "pipewire-gobject", + "missing", + True, + f"Pwg library {actual_version} is older than required {PWG_REQUIRED_VERSION}", + PWG_HINT, + ) + + if missing_symbols: + return DependencyCheck( + "pipewire-gobject", + "missing", + True, + f"Pwg library {actual_version} lacks required symbol(s): {', '.join(missing_symbols)}", + PWG_HINT, + ) + + return DependencyCheck( + "pipewire-gobject", + "ok", + True, + f"{shim_check.detail}; {namespace_check.detail}; Pwg library {actual_version}", + PWG_HINT, + ) + + def split_env_paths(value: str | None) -> list[Path]: if not value: return [] @@ -226,25 +302,25 @@ def check_spa_plugin(relative_path: str, label: str, required: bool, hint: str) return DependencyCheck(label, "missing", required, detail, hint) -def check_wireplumber_session() -> DependencyCheck: - command_check = check_command("wpctl", ["status"], "WirePlumber session", True, PIPEWIRE_HINT) +def check_pipewire_session() -> DependencyCheck: + command_check = check_command("wpctl", ["status"], "PipeWire session", True, PIPEWIRE_HINT) if command_check.ok: return command_check try: - from .wireplumber_backend import WirePlumberBackend + from .pipewire_backend import PipeWireBackend - with WirePlumberBackend(timeout_ms=1000): + with PipeWireBackend(timeout_ms=1000): pass except Exception as exc: - detail = f"{command_check.detail}; WirePlumber GI connection failed: {exc}" - return DependencyCheck("WirePlumber session", "missing", True, detail, PIPEWIRE_HINT) + detail = f"{command_check.detail}; Pwg PipeWire connection failed: {exc}" + return DependencyCheck("PipeWire session", "missing", True, detail, PIPEWIRE_HINT) return DependencyCheck( - "WirePlumber session", + "PipeWire session", "ok", True, - "connected to PipeWire through WirePlumber GI", + "connected to PipeWire through Pwg", PIPEWIRE_HINT, ) @@ -293,8 +369,8 @@ def collect_dependency_checks() -> list[DependencyCheck]: check_gi_repository("Gsk", "4.0", "GSK 4 GI namespace", True, GTK_HINT), check_gi_repository("Graphene", "1.0", "Graphene GI namespace", True, GTK_HINT), check_gi_repository_attribute("Adw", "1", "WrapBox", "Libadwaita 1.7+ GI namespace", True, ADW_HINT), - check_first_available_gi_repository("Wp", WIREPLUMBER_GI_VERSIONS, "WirePlumber GI namespace", True, WP_HINT), - check_wireplumber_session(), + check_pipewire_gobject(), + check_pipewire_session(), check_pipewire_module( "libpipewire-module-filter-chain.so", "PipeWire filter-chain module", @@ -308,7 +384,6 @@ def collect_dependency_checks() -> list[DependencyCheck]: PIPEWIRE_FILTER_CHAIN_HINT, ), check_python_import("numpy", "NumPy FFT analyzer", False, NUMPY_HINT), - check_python_import("jack", "Python JACK analyzer client", False, JACK_HINT), check_native_ebur128(), ] diff --git a/src/mini_eq/desktop_integration.py b/src/mini_eq/desktop_integration.py index 9069e42..2bbd3db 100644 --- a/src/mini_eq/desktop_integration.py +++ b/src/mini_eq/desktop_integration.py @@ -70,7 +70,7 @@ def build_desktop_file() -> str: f"Name={APP_DISPLAY_NAME}", "GenericName=System-wide Equalizer", "Comment=Minimal system-wide parametric equalizer for PipeWire", - "Keywords=equalizer;audio;pipewire;jack;", + "Keywords=equalizer;audio;pipewire;", "Categories=GTK;AudioVideo;Audio;", f"Exec={exec_line}", f"Icon={APP_ICON_NAME}", diff --git a/src/mini_eq/filter_chain.py b/src/mini_eq/filter_chain.py index 3c1b969..5fa7f9e 100644 --- a/src/mini_eq/filter_chain.py +++ b/src/mini_eq/filter_chain.py @@ -224,6 +224,8 @@ def build_builtin_biquad_filter_chain_module_args( node.name = {pipewire_quote(virtual_sink_name)} node.description = {pipewire_quote(VIRTUAL_SINK_DESCRIPTION)} media.class = Audio/Sink + state.restore-props = false + state.restore-target = false audio.channels = 2 audio.position = [ FL FR ] }} @@ -232,6 +234,8 @@ def build_builtin_biquad_filter_chain_module_args( node.description = {pipewire_quote(OUTPUT_CLIENT_NAME)} node.passive = true target.object = {pipewire_quote(output_sink)} + state.restore-props = false + state.restore-target = false audio.channels = 2 audio.position = [ FL FR ] }} diff --git a/src/mini_eq/instance.py b/src/mini_eq/instance.py index daea0b7..1b75e9c 100644 --- a/src/mini_eq/instance.py +++ b/src/mini_eq/instance.py @@ -121,7 +121,7 @@ def is_mini_eq_python_cmdline(cmdline: Iterable[str]) -> bool: has_module_invocation = any( args[index] == "-m" and index + 1 < len(args) and args[index + 1] == "mini_eq" for index in range(len(args) - 1) ) - has_standalone_script = any(Path(arg).name in {"mini_eq.py", "mini-eq"} for arg in args) + has_standalone_script = len(args) > 1 and Path(args[1]).name in {"mini_eq.py", "mini-eq"} return has_module_invocation or has_standalone_script diff --git a/src/mini_eq/pipewire_backend.py b/src/mini_eq/pipewire_backend.py new file mode 100644 index 0000000..de96bcb --- /dev/null +++ b/src/mini_eq/pipewire_backend.py @@ -0,0 +1,640 @@ +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from typing import Any + +DEFAULT_METADATA_NAME = "default" +DEFAULT_AUDIO_SINK_KEY = "default.audio.sink" +DEFAULT_CONFIGURED_AUDIO_SINK_KEY = "default.configured.audio.sink" +TARGET_OBJECT_KEY = "target.object" +TARGET_NODE_KEY = "target.node" +SPA_ID_TYPE = "Spa:Id" +PIPEWIRE_NODE_INTERFACE = "PipeWire:Interface:Node" +STREAM_OUTPUT_AUDIO = "Stream/Output/Audio" +AUDIO_SINK = "Audio/Sink" +FILTER_CHAIN_MODULE_NAME = "libpipewire-module-filter-chain" +PIPEWIRE_APPLICATION_NAME_KEY = "application.name" +PIPEWIRE_MEDIA_CATEGORY_KEY = "media.category" +PIPEWIRE_CLIENT_NAME = "Mini EQ" +PIPEWIRE_MEDIA_CATEGORY = "Manager" + + +@dataclass(frozen=True) +class PipeWireNode: + bound_id: int + object_serial: str | None + media_class: str | None + node_name: str | None + node_description: str | None + application_name: str | None + node_dont_move: bool + properties: dict[str, str] = field(default_factory=dict) + + @property + def is_audio_sink(self) -> bool: + return self.media_class == AUDIO_SINK + + @property + def is_output_stream(self) -> bool: + return self.media_class == STREAM_OUTPUT_AUDIO + + @property + def display_name(self) -> str: + return self.node_description or self.application_name or self.node_name or f"node {self.bound_id}" + + def property_value(self, key: str, default: str = "") -> str: + return self.properties.get(key, default) + + +@dataclass(frozen=True) +class PipeWireDefaults: + default_audio_sink: str | None + configured_audio_sink: str | None + + +@dataclass(frozen=True) +class PipeWireStreamTarget: + target_node: str | None + target_node_type: str | None + target_object: str | None + target_object_type: str | None + + +class PipeWireBackendError(RuntimeError): + pass + + +def build_props_controls_param(Pwg, GLib, controls: dict[str, float]): + variant = GLib.Variant("a{sd}", {name: float(value) for name, value in controls.items()}) + param = Pwg.Param.new_props_controls(variant) + if param is None: + raise PipeWireBackendError("failed to build PipeWire node control parameter") + return param + + +def parse_metadata_node_name(value: str | None) -> str | None: + if not value: + return None + + try: + payload = json.loads(value) + except json.JSONDecodeError: + return value + + if not isinstance(payload, dict): + return None + + name = payload.get("name") + return str(name) if name else None + + +def parse_bool_property(value: str | None) -> bool: + return str(value).lower() in {"1", "true", "yes", "on"} + + +def parse_positive_int(value: str | None) -> int: + try: + parsed = int(value or "") + except (TypeError, ValueError): + return 0 + + return parsed if parsed > 0 else 0 + + +def parse_rate_from_latency(value: str | None) -> int: + if not value or "/" not in value: + return 0 + + _frames, rate = value.rsplit("/", 1) + return parse_positive_int(rate) + + +def node_sample_rate(node: PipeWireNode | None) -> float: + if node is None: + return 0.0 + + rate = parse_positive_int(node.property_value("audio.rate")) + if rate <= 0: + rate = parse_rate_from_latency(node.property_value("node.max-latency")) + if rate <= 0: + rate = parse_rate_from_latency(node.property_value("node.latency")) + + return float(rate) if rate > 0 else 0.0 + + +class PipeWireBackend: + def __init__(self, timeout_ms: int = 2000) -> None: + self.timeout_ms = timeout_ms + self._connected = False + self._GLib: Any = None + self._GObject: Any = None + self._Pwg: Any = None + self._core: Any = None + self._registry: Any = None + self._metadata: Any = None + self._metadata_signal_objects: dict[int, Any] = {} + self._node_signal_objects: dict[int, Any] = {} + self._node_proxies: dict[int, Any] = {} + self._loaded_modules: list[Any] = [] + self._cached_defaults = PipeWireDefaults(None, None) + + def __enter__(self) -> PipeWireBackend: + self.connect() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def connect(self) -> None: + if self._connected: + return + + GLib, GObject, Pwg = self._import_pipewire_gobject() + self._GLib = GLib + self._GObject = GObject + self._Pwg = Pwg + + Pwg.init() + self._core = self._new_core(Pwg) + if not self._core.connect(): + raise PipeWireBackendError("failed to connect to PipeWire") + + self._registry = Pwg.Registry.new(self._core) + if not self._registry.start(): + raise PipeWireBackendError("failed to start PipeWire registry discovery") + + self._metadata = Pwg.Metadata.new(self._core, DEFAULT_METADATA_NAME) + if not self._metadata.start(): + raise PipeWireBackendError("failed to start PipeWire default metadata discovery") + + self._wait_for_initial_state() + self._connected = True + + def close(self) -> None: + for handler_id, obj in list(self._metadata_signal_objects.items()): + try: + obj.disconnect(handler_id) + except Exception: + pass + self._metadata_signal_objects.clear() + + for handler_id, obj in list(self._node_signal_objects.items()): + try: + obj.disconnect(handler_id) + except Exception: + pass + self._node_signal_objects.clear() + + for node in list(self._node_proxies.values()): + try: + node.stop() + except Exception: + pass + self._node_proxies.clear() + + for module in list(self._loaded_modules): + try: + module.unload() + except Exception: + pass + self._loaded_modules.clear() + + if self._metadata is not None: + try: + self._metadata.stop() + except Exception: + pass + if self._registry is not None: + try: + self._registry.stop() + except Exception: + pass + if self._core is not None: + try: + self._core.disconnect() + except Exception: + pass + + self._connected = False + self._core = None + self._registry = None + self._metadata = None + self._cached_defaults = PipeWireDefaults(None, None) + + def list_nodes(self) -> list[PipeWireNode]: + self._ensure_connected() + nodes: list[PipeWireNode] = [] + + for global_ in self._iterate_model(self._registry.dup_globals_by_interface(PIPEWIRE_NODE_INTERFACE)): + try: + nodes.append(self._node_from_global(global_)) + except UnicodeDecodeError: + continue + + return nodes + + def list_audio_sinks(self) -> list[PipeWireNode]: + return [node for node in self.list_nodes() if node.is_audio_sink] + + def list_output_streams(self) -> list[PipeWireNode]: + return [node for node in self.list_nodes() if node.is_output_stream] + + def node_from_proxy(self, node) -> PipeWireNode: + return self._node_from_global(node) + + def connect_object_added(self, callback) -> int: + self._ensure_connected() + handler_id = self._GObject.Object.connect(self._registry, "global-added", callback) + self._node_signal_objects[handler_id] = self._registry + return handler_id + + def connect_object_removed(self, callback) -> int: + self._ensure_connected() + handler_id = self._GObject.Object.connect(self._registry, "global-removed", callback) + self._node_signal_objects[handler_id] = self._registry + return handler_id + + def disconnect_node_manager_handler(self, handler_id: int) -> None: + if handler_id <= 0: + return + + obj = self._node_signal_objects.pop(handler_id, None) + if obj is None: + return + + try: + obj.disconnect(handler_id) + except Exception: + pass + + def connect_metadata_changed(self, callback) -> int: + metadata = self._default_metadata() + handler_id = self._GObject.Object.connect(metadata, "changed", callback) + self._metadata_signal_objects[handler_id] = metadata + return handler_id + + def disconnect_metadata_handler(self, handler_id: int) -> None: + if handler_id <= 0: + return + + metadata = self._metadata_signal_objects.pop(handler_id, None) + if metadata is None: + return + + try: + metadata.disconnect(handler_id) + except Exception: + pass + + def sync(self) -> None: + self._ensure_connected() + self._sync_core() + + def defaults(self) -> PipeWireDefaults: + if self._has_cached_defaults(): + return self._cached_defaults + + return self.refresh_defaults() + + def refresh_defaults(self) -> PipeWireDefaults: + try: + self._cached_defaults = self._read_defaults() + return self._cached_defaults + except UnicodeDecodeError: + try: + self._sync_core() + self._cached_defaults = self._read_defaults() + return self._cached_defaults + except UnicodeDecodeError as retry_exc: + if self._has_cached_defaults(): + return self._cached_defaults + raise PipeWireBackendError( + "PipeWire metadata contains an undecodable default sink value" + ) from retry_exc + except Exception as retry_exc: + if self._has_cached_defaults(): + return self._cached_defaults + raise PipeWireBackendError(f"failed to refresh PipeWire defaults: {retry_exc}") from retry_exc + except Exception: + if self._has_cached_defaults(): + return self._cached_defaults + raise + + def remember_default_metadata_change(self, key: str, value: str | None) -> bool: + if key not in {DEFAULT_AUDIO_SINK_KEY, DEFAULT_CONFIGURED_AUDIO_SINK_KEY}: + return False + + node_name = parse_metadata_node_name(value) + if key == DEFAULT_AUDIO_SINK_KEY: + self._cached_defaults = PipeWireDefaults(node_name, self._cached_defaults.configured_audio_sink) + else: + self._cached_defaults = PipeWireDefaults(self._cached_defaults.default_audio_sink, node_name) + + return True + + def _read_defaults(self) -> PipeWireDefaults: + metadata = self._default_metadata() + return PipeWireDefaults( + default_audio_sink=metadata.dup_default_audio_sink_name(), + configured_audio_sink=metadata.dup_configured_audio_sink_name(), + ) + + def _has_cached_defaults(self) -> bool: + return bool(self._cached_defaults.default_audio_sink or self._cached_defaults.configured_audio_sink) + + def move_stream_to_target(self, stream_bound_id: int, target_node_name: str) -> None: + stream = self.output_stream_by_bound_id(stream_bound_id) + if stream is None: + raise PipeWireBackendError(f"output stream not found: {stream_bound_id}") + + if stream.node_dont_move: + raise PipeWireBackendError(f"stream is marked node.dont-move: {stream.display_name}") + + target = self.audio_sink_by_name(target_node_name) + if target is None: + raise PipeWireBackendError(f"audio sink not found: {target_node_name}") + + if not target.object_serial: + raise PipeWireBackendError(f"audio sink has no object.serial: {target_node_name}") + + self.set_stream_target(stream.bound_id, target.bound_id, target.object_serial) + + def stream_target(self, stream_bound_id: int) -> PipeWireStreamTarget: + metadata = self._default_metadata() + return PipeWireStreamTarget( + target_node=metadata.dup_value(stream_bound_id, TARGET_NODE_KEY), + target_node_type=metadata.dup_value_type(stream_bound_id, TARGET_NODE_KEY), + target_object=metadata.dup_value(stream_bound_id, TARGET_OBJECT_KEY), + target_object_type=metadata.dup_value_type(stream_bound_id, TARGET_OBJECT_KEY), + ) + + def set_stream_target(self, stream_bound_id: int, target_bound_id: int, target_serial: str) -> None: + metadata = self._default_metadata() + + # target.node keeps compatibility with older session-manager behavior, + # while target.object is the stable serial-based target used by modern + # WirePlumber policy. Pwg owns only the metadata write, not routing + # policy or acknowledgement semantics. + for key, value in ((TARGET_NODE_KEY, str(target_bound_id)), (TARGET_OBJECT_KEY, target_serial)): + if not metadata.set(stream_bound_id, key, SPA_ID_TYPE, value): + raise PipeWireBackendError(f"failed to set stream target metadata: {stream_bound_id}") + self._sync_core() + + def restore_stream_target(self, stream_bound_id: int, target: PipeWireStreamTarget) -> None: + metadata = self._default_metadata() + for key, type_name, value in ( + (TARGET_NODE_KEY, target.target_node_type, target.target_node), + (TARGET_OBJECT_KEY, target.target_object_type, target.target_object), + ): + if not metadata.set(stream_bound_id, key, type_name, value): + raise PipeWireBackendError(f"failed to restore stream target metadata: {stream_bound_id}") + self._sync_core() + + def output_stream_by_bound_id(self, bound_id: int) -> PipeWireNode | None: + for stream in self.list_output_streams(): + if stream.bound_id == bound_id: + return stream + + return None + + def output_stream_by_name(self, node_name: str) -> PipeWireNode | None: + for stream in self.list_output_streams(): + if stream.node_name == node_name: + return stream + + return None + + def move_named_output_stream_to_target(self, stream_node_name: str, target_node_name: str) -> None: + stream = self.output_stream_by_name(stream_node_name) + if stream is None: + raise PipeWireBackendError(f"output stream not found: {stream_node_name}") + + self.move_stream_to_target(stream.bound_id, target_node_name) + + def audio_sink_by_name(self, node_name: str) -> PipeWireNode | None: + for sink in self.list_audio_sinks(): + if sink.node_name == node_name: + return sink + + return None + + def set_node_params(self, node_bound_id: int, controls: dict[str, float]) -> None: + self._ensure_connected() + + node = self._node_proxy_by_bound_id(node_bound_id) + if node is None: + raise PipeWireBackendError(f"node not found: {node_bound_id}") + + param = build_props_controls_param(self._Pwg, self._GLib, controls) + if not node.set_param(param): + raise PipeWireBackendError(f"failed to set node params: {node_bound_id}") + + def load_filter_chain_module(self, arguments: str): + self._ensure_connected() + + module = self._core.load_module(FILTER_CHAIN_MODULE_NAME, arguments) + if module is None: + raise PipeWireBackendError(f"failed to load PipeWire module: {FILTER_CHAIN_MODULE_NAME}") + + self._loaded_modules.append(module) + return module + + def unload_filter_chain_module(self, module) -> None: + if module is None: + return + + try: + module.unload() + finally: + try: + self._loaded_modules.remove(module) + except ValueError: + pass + + @staticmethod + def _new_core(Pwg): + core = Pwg.Core.new() + set_pipewire_property = getattr(core, "set_pipewire_property", None) + if set_pipewire_property is not None: + set_pipewire_property(PIPEWIRE_APPLICATION_NAME_KEY, PIPEWIRE_CLIENT_NAME) + set_pipewire_property(PIPEWIRE_MEDIA_CATEGORY_KEY, PIPEWIRE_MEDIA_CATEGORY) + return core + + def _default_metadata(self): + self._ensure_connected() + + if self._metadata is None or not self._metadata.get_bound(): + raise PipeWireBackendError("default PipeWire metadata object not found") + return self._metadata + + def _node_from_global(self, global_) -> PipeWireNode: + properties = self._properties_dict(global_) + return PipeWireNode( + bound_id=int(global_.get_id()), + object_serial=self._pw_property(global_, "object.serial", properties), + media_class=self._pw_property(global_, "media.class", properties), + node_name=self._pw_property(global_, "node.name", properties), + node_description=self._pw_property(global_, "node.description", properties), + application_name=self._pw_property(global_, "application.name", properties), + node_dont_move=parse_bool_property(self._pw_property(global_, "node.dont-move", properties)), + properties=properties, + ) + + def _node_proxy_by_bound_id(self, bound_id: int): + global_ = self._registry.lookup_global(int(bound_id)) + if global_ is None or not global_.is_node(): + return None + + node = self._node_proxies.get(int(bound_id)) + if node is not None and node.get_running(): + return node + + node = self._Pwg.Node.new(self._core, global_) + if node is None: + return None + if not node.start(): + raise PipeWireBackendError(f"failed to bind node: {bound_id}") + + self._node_proxies[int(bound_id)] = node + return node + + def _pw_property(self, global_, key: str, properties: dict[str, str] | None = None) -> str | None: + if properties is not None and key in properties: + return properties[key] + + try: + value = global_.dup_property(key) + return str(value) if value is not None else None + except (AttributeError, TypeError, UnicodeDecodeError): + pass + + try: + props = self._properties_dict(global_) + return props.get(key) + except Exception: + return None + + def _properties_dict(self, global_) -> dict[str, str]: + try: + props = global_.get_properties() + except Exception: + return {} + + result: dict[str, str] = {} + if hasattr(props, "new_iterator"): + iterator = props.new_iterator() + while True: + try: + ok, item = iterator.next() + except TypeError: + break + + if not ok or item is None: + break + + try: + key = item.get_key() + value = item.get_value() + key_text = str(key) if key is not None else None + value_text = str(value) if value is not None else None + except UnicodeDecodeError: + continue + + if key_text is not None and value_text is not None: + result[key_text] = value_text + return result + + try: + values = props.unpack() + except AttributeError: + values = props + except Exception: + return {} + + try: + items = values.items() + except AttributeError: + return result + + for key, value in items: + try: + key_text = str(key) if key is not None else None + value_text = str(value) if value is not None else None + except UnicodeDecodeError: + continue + + if key_text is not None and value_text is not None: + result[key_text] = value_text + + return result + + def _iterate_model(self, model) -> list[Any]: + if model is None: + return [] + + try: + count = int(model.get_n_items()) + except Exception: + return [] + + return [item for index in range(count) if (item := model.get_item(index)) is not None] + + def _ensure_connected(self) -> None: + if not self._connected: + self.connect() + + def _wait_for_initial_state(self) -> None: + deadline = time.monotonic() + (self.timeout_ms / 1000.0) + context = self._GLib.MainContext.default() + + while time.monotonic() < deadline: + while context.pending(): + context.iteration(False) + + registry_ready = self._registry.get_globals().get_n_items() > 0 + metadata_ready = self._metadata.get_bound() + if registry_ready and metadata_ready: + return + + time.sleep(0.01) + + missing = [] + if self._registry.get_globals().get_n_items() <= 0: + missing.append("registry") + if not self._metadata.get_bound(): + missing.append("metadata") + raise PipeWireBackendError(f"PipeWire initialization timed out waiting for: {', '.join(missing)}") + + def _sync_core(self) -> None: + if self._GLib is None: + return + + deadline = time.monotonic() + min(self.timeout_ms / 1000.0, 0.05) + context = self._GLib.MainContext.default() + while time.monotonic() < deadline and context.pending(): + context.iteration(False) + + @staticmethod + def _import_pipewire_gobject(): + shim_error: Exception | None = None + try: + import pipewire_gobject # noqa: F401 + except Exception as exc: # pragma: no cover - depends on installed packaging layout + shim_error = exc + + try: + import gi + + gi.require_version("Pwg", "0.1") + from gi.repository import GLib, GObject, Pwg + except Exception as exc: + if shim_error is not None: + raise PipeWireBackendError( + f"pipewire-gobject is not available: Python shim failed with {shim_error}; " + f"Pwg GI import failed with {exc}" + ) from exc + raise + + return GLib, GObject, Pwg diff --git a/src/mini_eq/wireplumber_stream_router.py b/src/mini_eq/pipewire_stream_router.py similarity index 64% rename from src/mini_eq/wireplumber_stream_router.py rename to src/mini_eq/pipewire_stream_router.py index dd379ee..a1f6b50 100644 --- a/src/mini_eq/wireplumber_stream_router.py +++ b/src/mini_eq/pipewire_stream_router.py @@ -6,7 +6,13 @@ from .core import OUTPUT_CLIENT_NAME, VIRTUAL_SINK_BASE from .glib_utils import destroy_glib_source -from .wireplumber_backend import STREAM_OUTPUT_AUDIO, WirePlumberBackend, WirePlumberError, WirePlumberNode +from .pipewire_backend import ( + STREAM_OUTPUT_AUDIO, + PipeWireBackend, + PipeWireBackendError, + PipeWireNode, + PipeWireStreamTarget, +) BLOCKLIST_MEDIA_ROLES = {"event", "Notification"} BLOCKLIST_STREAM_NAMES = { @@ -20,24 +26,25 @@ } -class WirePlumberStreamRouter: +class PipeWireStreamRouter: def __init__( self, virtual_sink_name: str, internal_output_name: str, status_callback: Callable[[str], None], - backend: WirePlumberBackend | None = None, + backend: PipeWireBackend | None = None, ) -> None: self.virtual_sink_name = virtual_sink_name self.internal_output_name = internal_output_name self.status_callback = status_callback - self.backend = backend or WirePlumberBackend() + self.backend = backend or PipeWireBackend() self.owns_backend = backend is None self.enabled = False self.accept_stream_events = False self.event_source_id = 0 self.object_added_handler_id = 0 self.routed_stream_ids: set[int] = set() + self.routed_stream_targets: dict[int, PipeWireStreamTarget] = {} self.output_sink_name: str | None = None self.last_warning_message = "" @@ -55,7 +62,7 @@ def emit_warning(self, exc: Exception) -> None: def set_output_sink_name(self, sink_name: str) -> None: self.output_sink_name = sink_name - def _is_internal_stream(self, stream: WirePlumberNode) -> bool: + def _is_internal_stream(self, stream: PipeWireNode) -> bool: node_name = stream.node_name or "" app_name = stream.application_name or "" media_role = stream.property_value("media.role") @@ -69,16 +76,66 @@ def _is_internal_stream(self, stream: WirePlumberNode) -> bool: or node_name.startswith(f"{self.virtual_sink_name}.") ) - def iter_routable_output_streams(self) -> list[WirePlumberNode]: - streams: list[WirePlumberNode] = [] + def _target_object_matches_processing_path(self, target_object: str) -> bool: + allowed_targets = {self.virtual_sink_name} + if self.output_sink_name: + allowed_targets.add(self.output_sink_name) + + for node_name in tuple(allowed_targets): + try: + node = self.backend.audio_sink_by_name(node_name) + except Exception: + node = None + + if node is None: + continue + if node.node_name: + allowed_targets.add(node.node_name) + if node.object_serial: + allowed_targets.add(str(node.object_serial)) + + return target_object in allowed_targets + + def _has_foreign_target_object(self, stream: PipeWireNode) -> bool: + target_object = stream.property_value("target.object") + return bool(target_object) and not self._target_object_matches_processing_path(target_object) + + def _virtual_sink_target_values(self) -> set[str]: + values = {self.virtual_sink_name} + try: + virtual_sink = self.backend.audio_sink_by_name(self.virtual_sink_name) + except Exception: + virtual_sink = None + + if virtual_sink is not None: + if virtual_sink.node_name: + values.add(virtual_sink.node_name) + if virtual_sink.object_serial: + values.add(str(virtual_sink.object_serial)) + values.add(str(virtual_sink.bound_id)) + + return values + + def _target_points_to_virtual_sink(self, target: PipeWireStreamTarget) -> bool: + target_values = {value for value in (target.target_node, target.target_object) if value} + return bool(target_values & self._virtual_sink_target_values()) + + def _stream_target_before_route(self, stream_bound_id: int) -> PipeWireStreamTarget: + target = self.backend.stream_target(stream_bound_id) + if self._target_points_to_virtual_sink(target): + return PipeWireStreamTarget(None, None, None, None) + return target + + def iter_routable_output_streams(self) -> list[PipeWireNode]: + streams: list[PipeWireNode] = [] for stream in self.backend.list_output_streams(): - if not self._is_internal_stream(stream): + if not self._is_internal_stream(stream) and not self._has_foreign_target_object(stream): streams.append(stream) return streams def is_stale_stream_error(self, exc: Exception, stream_id: int) -> bool: - return isinstance(exc, WirePlumberError) and str(exc) == f"output stream not found: {stream_id}" + return isinstance(exc, PipeWireBackendError) and str(exc) == f"output stream not found: {stream_id}" def route_output_streams(self) -> int: routed_now = 0 @@ -86,12 +143,16 @@ def route_output_streams(self) -> int: for stream in self.iter_routable_output_streams(): was_tracked = stream.bound_id in self.routed_stream_ids + original_target = self.routed_stream_targets.get(stream.bound_id) try: + if original_target is None: + original_target = self._stream_target_before_route(stream.bound_id) self.backend.move_stream_to_target(stream.bound_id, self.virtual_sink_name) except Exception as exc: if self.is_stale_stream_error(exc, stream.bound_id): self.routed_stream_ids.discard(stream.bound_id) + self.routed_stream_targets.pop(stream.bound_id, None) continue failures.append(exc) continue @@ -100,6 +161,7 @@ def route_output_streams(self) -> int: routed_now += 1 self.routed_stream_ids.add(stream.bound_id) + self.routed_stream_targets[stream.bound_id] = original_target if failures: raise failures[0] @@ -107,8 +169,9 @@ def route_output_streams(self) -> int: return routed_now def restore_output_streams(self) -> int: - if not self.output_sink_name: + if not self.output_sink_name and not self.routed_stream_targets: self.routed_stream_ids.clear() + self.routed_stream_targets.clear() return 0 streams = {stream.bound_id: stream for stream in self.iter_routable_output_streams()} @@ -121,10 +184,15 @@ def restore_output_streams(self) -> int: continue try: - self.backend.move_stream_to_target(stream_id, self.output_sink_name) + target = self.routed_stream_targets.get(stream_id) + if target is not None: + self.backend.restore_stream_target(stream_id, target) + elif self.output_sink_name: + self.backend.move_stream_to_target(stream_id, self.output_sink_name) except Exception as exc: if self.is_stale_stream_error(exc, stream_id): self.routed_stream_ids.discard(stream_id) + self.routed_stream_targets.pop(stream_id, None) continue failures.append(exc) continue @@ -134,6 +202,7 @@ def restore_output_streams(self) -> int: raise failures[0] self.routed_stream_ids.clear() + self.routed_stream_targets.clear() return restored def refresh(self, *, raise_errors: bool = False) -> bool: diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py index f4fb48b..a559276 100644 --- a/src/mini_eq/routing.py +++ b/src/mini_eq/routing.py @@ -42,22 +42,22 @@ builtin_biquad_preamp_control_values, ) from .glib_utils import destroy_glib_source -from .wireplumber_backend import ( +from .pipewire_backend import ( DEFAULT_AUDIO_SINK_KEY, DEFAULT_CONFIGURED_AUDIO_SINK_KEY, - WirePlumberBackend, - WirePlumberNode, + PipeWireBackend, + PipeWireNode, node_sample_rate, ) -from .wireplumber_stream_router import WirePlumberStreamRouter +from .pipewire_stream_router import PipeWireStreamRouter class SystemWideEqController: def __init__(self, output_sink: str | None) -> None: - self.output_backend = WirePlumberBackend() + self.output_backend = PipeWireBackend() self.output_backend.connect() self.virtual_sink_name = self.pick_virtual_sink_name() - self.original_default_sink = self.get_default_output_sink_name() + self.original_default_sink = self.resolve_default_output_sink_name() self.follow_default_output = output_sink is None self.output_sink = output_sink or self.original_default_sink self.filter_output_name = f"{self.virtual_sink_name}{FILTER_OUTPUT_SUFFIX}" @@ -80,7 +80,7 @@ def __init__(self, output_sink: str | None) -> None: self.preamp_db = 0.0 self.default_bands: list[EqBand] = self.build_default_bands() self.bands: list[EqBand] = [replace(band) for band in self.default_bands] - self.stream_router: WirePlumberStreamRouter | None = None + self.stream_router: PipeWireStreamRouter | None = None self.output_analyzer: OutputSpectrumAnalyzer | None = None self.analyzer_response_speed = ANALYZER_RESPONSE_DEFAULT @@ -120,7 +120,7 @@ def set_analyzer_loudness_callback( if self.output_analyzer is not None: self.output_analyzer.set_loudness_callback(callback) - def list_sinks(self) -> list[WirePlumberNode]: + def list_sinks(self) -> list[PipeWireNode]: return self.output_backend.list_audio_sinks() def list_output_sink_names(self) -> list[str]: @@ -130,22 +130,43 @@ def list_output_sink_names(self) -> list[str]: if sink.node_name is not None and not sink.node_name.startswith(VIRTUAL_SINK_BASE) ] - def get_sink(self, sink_name: str) -> WirePlumberNode | None: + def first_available_output_sink_name(self) -> str: + return next(iter(self.list_output_sink_names()), "") + + def get_sink(self, sink_name: str) -> PipeWireNode | None: if not sink_name: return None return self.output_backend.audio_sink_by_name(sink_name) - def get_default_output_sink_name(self) -> str: - defaults = self.output_backend.defaults() - return defaults.default_audio_sink or defaults.configured_audio_sink or "" + def default_output_sink_candidates(self, *, refresh: bool = False) -> tuple[str, ...]: + defaults = self.output_backend.refresh_defaults() if refresh else self.output_backend.defaults() + return tuple( + sink_name for sink_name in (defaults.configured_audio_sink, defaults.default_audio_sink) if sink_name + ) + + def get_default_output_sink_name(self, *, refresh: bool = False) -> str: + candidates = self.default_output_sink_candidates(refresh=refresh) + + for sink_name in candidates: + if self.is_valid_output_sink(sink_name) and self.get_sink(sink_name) is not None: + return sink_name + + return next(iter(candidates), "") + + def resolve_default_output_sink_name(self) -> str: + default_sink = self.get_default_output_sink_name(refresh=True) + if self.is_valid_output_sink(default_sink) and self.get_sink(default_sink) is not None: + return default_sink + + return self.first_available_output_sink_name() def is_valid_output_sink(self, sink_name: str) -> bool: return bool(sink_name) and not sink_name.startswith(VIRTUAL_SINK_BASE) - def ensure_stream_router(self) -> WirePlumberStreamRouter: + def ensure_stream_router(self) -> PipeWireStreamRouter: if self.stream_router is None: - self.stream_router = WirePlumberStreamRouter( + self.stream_router = PipeWireStreamRouter( self.virtual_sink_name, self.filter_output_name, self.emit_status, @@ -244,13 +265,14 @@ def refresh_followed_output_sink(self) -> bool: if not self.follow_default_output: return False - default_sink = self.get_default_output_sink_name() - - if self.is_valid_output_sink(default_sink) and self.get_sink(default_sink) is not None: + for default_sink in self.default_output_sink_candidates(refresh=True): + if not self.is_valid_output_sink(default_sink) or self.get_sink(default_sink) is None: + continue try: self.switch_output_sink(default_sink, explicit=False) except Exception as exc: self.emit_status(f"default output follow warning: {exc}") + break return True @@ -494,7 +516,8 @@ def restore_engine_after_analyzer_failure(self) -> None: self.stream_router.route_output_streams() def stop_engine(self, announce: bool = True) -> None: - if self.engine_module is None: + module = self.engine_module + if module is None: self.filter_node_id = None self.running = False return @@ -502,6 +525,11 @@ def stop_engine(self, announce: bool = True) -> None: self.engine_module = None self.filter_node_id = None + try: + self.output_backend.unload_filter_chain_module(module) + except Exception as exc: + self.emit_status(f"filter-chain PipeWire EQ unload warning: {exc}") + try: self.output_backend.sync() except Exception: @@ -598,9 +626,14 @@ def shutdown(self) -> None: if self.output_analyzer is not None: self.output_analyzer.close() finally: - # Avoid explicit wp_core_disconnect() on shutdown. With WirePlumber 0.5 - # this can intermittently double-destroy PipeWire proxies after - # restoring routed streams; process exit still tears the graph down. + try: + self.stop_engine(announce=False) + except Exception: + pass + try: + self.output_backend.close() + except Exception: + pass self.engine_module = None self.filter_node_id = None self.running = False diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py index 827a564..a825316 100644 --- a/src/mini_eq/window.py +++ b/src/mini_eq/window.py @@ -36,6 +36,7 @@ ) from .glib_utils import destroy_glib_source from .gtk_utils import create_dropdown_from_strings +from .pipewire_backend import PipeWireNode, node_sample_rate, parse_positive_int from .routing import SystemWideEqController from .settings import load_monitor_enabled from .window_analyzer import MiniEqWindowAnalyzerMixin @@ -46,7 +47,6 @@ from .window_presets import MiniEqWindowPresetMixin from .window_utility import MiniEqWindowUtilityPaneMixin from .window_utils import requested_switch_state, set_switch_confirmed_state -from .wireplumber_backend import WirePlumberNode, node_sample_rate, parse_positive_int TOAST_TIMEOUT_SECONDS = 2 MIN_WINDOW_WIDTH = 980 @@ -87,6 +87,7 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut self.controller = controller self.auto_route_on_startup = auto_route self.post_present_source_id = 0 + self.startup_auto_route_source_id = 0 self.post_present_ready = False self.present_after_setup = True self.responsive_layout_source_id = 0 @@ -98,7 +99,7 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut self.set_default_size(1360, DEFAULT_WINDOW_HEIGHT) self.set_size_request(self.min_window_width, self.compact_min_window_height) self.updating_ui = False - self.selected_band_index: int | None = None + self.selected_band_index: int | None = 0 self.visible_band_count = DEFAULT_ACTIVE_BANDS self.band_fader_boxes: list[Gtk.Box] = [] self.band_fader_widgets: list[EqBandFader] = [] @@ -113,6 +114,10 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut self.current_preset_name: str | None = None self.saved_preset_signature = self.controller.state_signature() self.default_preset_signature = self.controller.default_state_signature() + self.curve_revert_baseline_label: str | None = None + self.curve_revert_baseline_signature: str | None = None + self.curve_revert_baseline_payload: dict[str, object] | None = None + self.set_curve_revert_baseline("Neutral") self.output_preset_auto_applied = False self.output_preset_curve_auto_loaded = False self.updating_output_preset_switch = False @@ -244,30 +249,35 @@ def on_post_present_setup_idle(self) -> bool: if self.ui_shutting_down: return False - if self.auto_route_on_startup: - try: - self.controller.route_system_audio(True) - except Exception as exc: - self.set_status(str(exc)) - else: - self.update_eq_power_indicator() - self.update_info_label() - self.update_status_summary() - self.update_focus_summary() - finally: - self.updating_ui = True - try: - set_switch_confirmed_state(self.route_switch, self.controller.routed) - finally: - self.updating_ui = False - self.start_analyzer_preview() self.notify_control_state_changed() + if self.auto_route_on_startup: + self.schedule_startup_auto_route() if not self.ui_shutting_down and self.present_after_setup: self.present() return False + def schedule_startup_auto_route(self) -> None: + if self.startup_auto_route_source_id != 0: + return + + self.startup_auto_route_source_id = GLib.idle_add(self.on_startup_auto_route_idle) + + def on_startup_auto_route_idle(self) -> bool: + self.startup_auto_route_source_id = 0 + + if self.ui_shutting_down or not self.auto_route_on_startup: + return False + + eq_was_enabled = self.controller.eq_enabled + try: + self.controller.route_system_audio(True) + except Exception as exc: + self.set_status(str(exc)) + self.refresh_after_route_state_changed(eq_was_enabled=eq_was_enabled) + return False + def prepare_for_shutdown(self) -> None: if self.ui_shutting_down: return @@ -277,6 +287,9 @@ def prepare_for_shutdown(self) -> None: if self.post_present_source_id > 0: destroy_glib_source(self.post_present_source_id) self.post_present_source_id = 0 + if self.startup_auto_route_source_id > 0: + destroy_glib_source(self.startup_auto_route_source_id) + self.startup_auto_route_source_id = 0 if self.responsive_layout_source_id > 0: destroy_glib_source(self.responsive_layout_source_id) self.responsive_layout_source_id = 0 @@ -362,7 +375,7 @@ def begin_close_request_shutdown(self, *, force_quit: bool = False) -> None: update_background_status() return - routed = self.route_switch.get_active() + routed = self.is_system_routed() if routed: self.updating_ui = True try: @@ -424,6 +437,55 @@ def notify_control_presets_changed(self) -> None: if callback is not None: callback() + def sync_control_switches_from_controller(self, *, route: bool = True, eq: bool = True) -> None: + self.updating_ui = True + try: + if route: + set_switch_confirmed_state(self.route_switch, self.controller.routed) + if eq: + set_switch_confirmed_state(self.bypass_switch, self.controller.eq_enabled) + finally: + self.updating_ui = False + + def refresh_after_route_state_changed( + self, + *, + eq_was_enabled: bool, + announce_enabled: bool | None = None, + notify: bool = True, + ) -> None: + self.sync_control_switches_from_controller() + self.update_eq_power_indicator() + self.update_info_label() + self.update_status_summary() + self.update_focus_summary() + if not eq_was_enabled and self.controller.eq_enabled: + self.invalidate_graph_response_cache() + self.queue_graph_draw() + self.update_preset_state() + if announce_enabled is not None: + self.set_status("System-wide EQ On" if announce_enabled else "System-wide EQ Off") + if notify: + self.notify_control_state_changed() + + def refresh_after_eq_state_changed( + self, + *, + announce_enabled: bool | None = None, + notify: bool = True, + ) -> None: + self.sync_control_switches_from_controller(route=False) + self.update_eq_power_indicator() + self.update_info_label() + self.update_status_summary() + self.invalidate_graph_response_cache() + self.queue_graph_draw() + self.update_preset_state() + if announce_enabled is not None: + self.set_status("Equalizer On" if announce_enabled else "Equalizer Off") + if notify: + self.notify_control_state_changed() + def start_preset_monitoring(self) -> None: if self.preset_monitor is not None: return @@ -488,10 +550,10 @@ def on_close_request(self, window: Gtk.Window) -> bool: self.begin_close_request_shutdown() return True - def output_sink_info(self) -> WirePlumberNode | None: + def output_sink_info(self) -> PipeWireNode | None: return self.controller.get_sink(self.controller.output_sink) - def format_sample_spec(self, sink: WirePlumberNode | None) -> str: + def format_sample_spec(self, sink: PipeWireNode | None) -> str: if sink is None: return "Unavailable" @@ -505,7 +567,7 @@ def format_sample_spec(self, sink: WirePlumberNode | None) -> str: return channel_text - def transport_label_for_sink(self, sink: WirePlumberNode | None) -> str: + def transport_label_for_sink(self, sink: PipeWireNode | None) -> str: if sink is None: return "Unavailable" @@ -523,7 +585,7 @@ def transport_label_for_sink(self, sink: WirePlumberNode | None) -> str: return api.upper() if api else "Audio output" - def output_display_name(self, sink: WirePlumberNode | None) -> str: + def output_display_name(self, sink: PipeWireNode | None) -> str: if sink is None: return self.controller.output_sink @@ -534,14 +596,14 @@ def output_display_name(self, sink: WirePlumberNode | None) -> str: or self.controller.output_sink ) - def list_visible_output_sinks(self) -> list[WirePlumberNode]: + def list_visible_output_sinks(self) -> list[PipeWireNode]: return [ sink for sink in self.controller.list_sinks() if sink.node_name is not None and self.controller.is_valid_output_sink(sink.node_name) ] - def build_output_sink_labels(self, sinks: list[WirePlumberNode]) -> list[str]: + def build_output_sink_labels(self, sinks: list[PipeWireNode]) -> list[str]: labels = [self.output_display_name(sink) for sink in sinks] counts: dict[str, int] = {} @@ -566,7 +628,7 @@ def follow_default_output_label(self) -> str: return f"Follow system output ({self.output_display_name(default_sink)})" - def profile_summary(self, sink: WirePlumberNode | None) -> tuple[str, str, bool, list[str]]: + def profile_summary(self, sink: PipeWireNode | None) -> tuple[str, str, bool, list[str]]: if sink is None: return "No output", "The selected sink is not available.", True, ["Selected output sink is unavailable."] @@ -594,7 +656,7 @@ def estimate_curve_peak_db(self) -> float: def update_status_summary(self) -> None: sink = self.output_sink_info() - route_enabled = self.route_switch.get_active() + route_enabled = self.is_system_routed() warnings = self.profile_summary(sink)[3] headroom_needs_attention = False @@ -734,6 +796,9 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> imported_count = self.controller.import_apo_preset(path) self.selected_band_index = None self.set_visible_band_count(imported_count) + self.current_preset_name = None + self.saved_preset_signature = self.controller.state_signature() + self.set_curve_revert_baseline("Imported APO Preset") self.output_preset_curve_auto_loaded = False self.sync_ui_from_state() except Exception as exc: @@ -743,6 +808,9 @@ def on_clear_clicked(self, button: Gtk.Button) -> None: self.controller.reset_state() self.selected_band_index = None self.set_visible_band_count(DEFAULT_ACTIVE_BANDS) + self.current_preset_name = None + self.saved_preset_signature = self.controller.state_signature() + self.set_curve_revert_baseline("Neutral") self.output_preset_curve_auto_loaded = False self.sync_ui_from_state() self.set_status("Equalizer Reset") @@ -803,32 +871,13 @@ def on_route_changed(self, switch: Gtk.Switch, state: object | None) -> bool: try: self.controller.route_system_audio(enabled) route_changed = True - self.updating_ui = True - try: - set_switch_confirmed_state(switch, self.controller.routed) - if self.controller.eq_enabled != eq_was_enabled: - set_switch_confirmed_state(self.bypass_switch, self.controller.eq_enabled) - finally: - self.updating_ui = False except Exception as exc: - self.updating_ui = True - try: - set_switch_confirmed_state(switch, self.controller.routed) - finally: - self.updating_ui = False self.set_status(str(exc)) finally: - self.update_eq_power_indicator() - self.update_info_label() - self.update_status_summary() - self.update_focus_summary() - if not eq_was_enabled and self.controller.eq_enabled: - self.invalidate_graph_response_cache() - self.queue_graph_draw() - self.update_preset_state() - if route_changed: - self.set_status("System-wide EQ On" if enabled else "System-wide EQ Off") - self.notify_control_state_changed() + self.refresh_after_route_state_changed( + eq_was_enabled=eq_was_enabled, + announce_enabled=enabled if route_changed else None, + ) return True def on_bypass_changed(self, switch: Gtk.Switch, state: object | None) -> bool: @@ -840,25 +889,9 @@ def on_bypass_changed(self, switch: Gtk.Switch, state: object | None) -> bool: try: self.controller.set_eq_enabled(enabled) - self.updating_ui = True - try: - set_switch_confirmed_state(switch, self.controller.eq_enabled) - finally: - self.updating_ui = False - self.update_eq_power_indicator() - self.update_info_label() - self.update_status_summary() - self.invalidate_graph_response_cache() - self.queue_graph_draw() - self.update_preset_state() - self.set_status("Equalizer On" if enabled else "Equalizer Off") - self.notify_control_state_changed() + self.refresh_after_eq_state_changed(announce_enabled=enabled) except Exception as exc: - self.updating_ui = True - try: - set_switch_confirmed_state(switch, self.controller.eq_enabled) - finally: - self.updating_ui = False + self.sync_control_switches_from_controller(route=False) self.update_eq_power_indicator() self.set_status(str(exc)) return True diff --git a/src/mini_eq/window_analyzer.py b/src/mini_eq/window_analyzer.py index dc7614b..90817dc 100644 --- a/src/mini_eq/window_analyzer.py +++ b/src/mini_eq/window_analyzer.py @@ -178,12 +178,7 @@ def start_analyzer_preview(self) -> None: if not started: self.analyzer_enabled = False - self.updating_ui = True - try: - set_switch_confirmed_state(self.analyzer_switch, False) - finally: - self.updating_ui = False - self.sync_ui_from_state() + self.refresh_after_monitor_state_changed(monitor_visibility_changed=True) return self.start_analyzer_preview_clock() @@ -400,8 +395,38 @@ def on_analyzer_preview_tick(self, now: float | None = None) -> bool: return True + def _sync_monitor_controls_unlocked(self) -> None: + set_switch_confirmed_state(self.analyzer_switch, self.analyzer_enabled) + set_switch_confirmed_state(self.analyzer_freeze_switch, self.analyzer_frozen) + self.analyzer_state_label.set_text( + "Frozen" if self.analyzer_frozen and self.analyzer_enabled else ("Live" if self.analyzer_enabled else "Off") + ) + self.update_analyzer_summary_label() + + def sync_monitor_controls_from_state(self) -> None: + self.updating_ui = True + try: + self._sync_monitor_controls_unlocked() + finally: + self.updating_ui = False + + def refresh_after_monitor_state_changed( + self, + *, + monitor_visibility_changed: bool = False, + notify: bool = True, + ) -> None: + self.sync_monitor_controls_from_state() + if monitor_visibility_changed: + self.invalidate_graph_background_cache() + self.queue_graph_draw() + self.queue_analyzer_draw(force=True) + if notify: + self.emit_control_state_changed() + def on_analyzer_changed(self, switch: Gtk.Switch, state: object | None) -> bool: enabled = requested_switch_state(switch, state) + del switch if self.updating_ui: return False @@ -425,13 +450,7 @@ def on_analyzer_changed(self, switch: Gtk.Switch, state: object | None) -> bool: self.queue_analyzer_draw(force=True) self.emit_control_analyzer_levels_changed() save_monitor_enabled(self.analyzer_enabled) - self.sync_ui_from_state() - self.updating_ui = True - try: - set_switch_confirmed_state(switch, self.analyzer_enabled) - finally: - self.updating_ui = False - self.emit_control_state_changed() + self.refresh_after_monitor_state_changed(monitor_visibility_changed=enabled != previous_enabled) return True def update_analyzer_summary_label(self) -> None: @@ -462,18 +481,13 @@ def update_analyzer_summary_label(self) -> None: def on_analyzer_freeze_changed(self, switch: Gtk.Switch, state: object | None) -> bool: frozen = requested_switch_state(switch, state) + del switch if self.updating_ui: return False self.analyzer_frozen = frozen - self.sync_ui_from_state() - self.updating_ui = True - try: - set_switch_confirmed_state(switch, self.analyzer_frozen) - finally: - self.updating_ui = False - self.emit_control_state_changed() + self.refresh_after_monitor_state_changed() return True def on_analyzer_smoothing_changed(self, scale: Gtk.Scale) -> None: diff --git a/src/mini_eq/window_graph.py b/src/mini_eq/window_graph.py index beffcbd..bbc85c6 100644 --- a/src/mini_eq/window_graph.py +++ b/src/mini_eq/window_graph.py @@ -56,6 +56,15 @@ def filter_type_label(filter_type: int) -> str: class MiniEqWindowGraphMixin: + def is_system_routed(self) -> bool: + controller = getattr(self, "controller", None) + routed = getattr(controller, "routed", None) + if routed is not None: + return bool(routed) + + route_switch = getattr(self, "route_switch", None) + return bool(route_switch is not None and route_switch.get_active()) + def is_dark_appearance(self) -> bool: application = self.get_application() style_manager = application.get_style_manager() if application is not None else None @@ -151,6 +160,9 @@ def update_band_fader(self, index: int, solo_active: bool | None = None) -> None box.add_css_class("eq-band-box-muted") def schedule_curve_metadata_refresh(self) -> None: + if not getattr(self, "ui_shutting_down", False): + self.update_preset_state() + if getattr(self, "curve_metadata_refresh_source_id", 0) != 0: return @@ -206,7 +218,7 @@ def update_focus_summary(self) -> None: self.band_count_label.set_text(selected_filter_type) self.band_count_label.set_visible(True) tooltip = f"{selected_filter_type} band at {format_frequency(selected.frequency)}, {selected.gain_db:+.1f} dB" - if not self.route_switch.get_active(): + if not self.is_system_routed(): tooltip = f"{tooltip}. System-wide EQ is off." self.focus_label.set_tooltip_text(tooltip) self.band_count_label.set_tooltip_text(tooltip) @@ -267,7 +279,7 @@ def update_eq_power_indicator(self) -> None: self.bypass_state_label.remove_css_class("compare-state-original") self.bypass_state_label.remove_css_class("compare-state-ready") - route_enabled = self.route_switch.get_active() + route_enabled = self.is_system_routed() self.bypass_switch.set_sensitive(route_enabled) if not route_enabled: @@ -289,18 +301,12 @@ def sync_ui_from_state(self) -> None: self.updating_ui = True try: + set_switch_confirmed_state(self.route_switch, self.controller.routed) set_switch_confirmed_state(self.bypass_switch, self.controller.eq_enabled) self.update_eq_power_indicator() - set_switch_confirmed_state(self.analyzer_switch, self.analyzer_enabled) - set_switch_confirmed_state(self.analyzer_freeze_switch, self.analyzer_frozen) - self.analyzer_state_label.set_text( - "Frozen" - if self.analyzer_frozen and self.analyzer_enabled - else ("Live" if self.analyzer_enabled else "Off") - ) + self._sync_monitor_controls_unlocked() self.analyzer_smoothing_label.set_text(f"{int(round(self.analyzer_smoothing * 100.0))}%") self.analyzer_display_gain_label.set_text(f"{self.analyzer_display_gain_db:+.0f} dB") - self.update_analyzer_summary_label() self.preamp_scale.set_value(self.controller.preamp_db) self.preamp_label.set_text(f"{self.controller.preamp_db:.1f} dB") self.mode_combo.set_selected(MODE_INDEX_BY_VALUE[self.controller.eq_mode]) diff --git a/src/mini_eq/window_presets.py b/src/mini_eq/window_presets.py index beb04e7..130f655 100644 --- a/src/mini_eq/window_presets.py +++ b/src/mini_eq/window_presets.py @@ -45,6 +45,32 @@ def default_preset_name(self) -> str | None: except Exception: return None + def set_curve_revert_baseline(self, label: str) -> None: + self.curve_revert_baseline_label = label + self.curve_revert_baseline_signature = self.controller.state_signature() + self.curve_revert_baseline_payload = self.controller.build_preset_payload(label) + + def clear_curve_revert_baseline(self) -> None: + self.curve_revert_baseline_label = None + self.curve_revert_baseline_signature = None + self.curve_revert_baseline_payload = None + + def curve_revert_label(self) -> str | None: + if self.current_preset_name is not None: + return self.current_preset_name + + return getattr(self, "curve_revert_baseline_label", None) + + def curve_revert_signature(self) -> str | None: + if self.current_preset_name is not None: + return self.saved_preset_signature + + return getattr(self, "curve_revert_baseline_signature", None) + + def has_curve_revert_changes(self) -> bool: + revert_signature = self.curve_revert_signature() + return revert_signature is not None and self.controller.state_signature() != revert_signature + def output_preset_is_active(self) -> bool: linked_preset = self.output_preset_link_name() return bool( @@ -147,11 +173,19 @@ def sync_output_preset_switch( def refresh_preset_actions(self) -> None: has_named_preset = self.current_preset_name is not None - has_preset_changes = has_named_preset and self.controller.state_signature() != self.saved_preset_signature + has_revert_target = self.curve_revert_signature() is not None + has_revert_changes = self.has_curve_revert_changes() + revert_label = self.curve_revert_label() or "curve baseline" self.preset_delete_button.set_sensitive(has_named_preset) self.preset_export_button.set_sensitive(True) self.preset_import_button.set_sensitive(True) - self.preset_revert_button.set_sensitive(has_preset_changes) + self.preset_revert_button.set_sensitive(has_revert_changes) + if not has_revert_target: + self.preset_revert_button.set_tooltip_text("Load or import a preset first") + elif has_revert_changes: + self.preset_revert_button.set_tooltip_text(f"Revert to {revert_label}") + else: + self.preset_revert_button.set_tooltip_text("No curve changes to revert") self.preset_save_button.set_sensitive(True) self.preset_save_as_button.set_sensitive(True) self.update_output_preset_state() @@ -220,7 +254,12 @@ def update_preset_state(self) -> None: self.preset_state_label.remove_css_class("preset-state-modified") self.preset_state_label.remove_css_class("preset-state-unsaved") - if self.current_preset_name is None: + if self.current_preset_name is None and self.has_curve_revert_changes(): + revert_label = self.curve_revert_label() or "Current curve" + self.preset_state_label.set_text("Modified") + self.preset_state_label.add_css_class("preset-state-modified") + self.preset_state_label.set_tooltip_text(f"{revert_label} has unsaved curve changes") + elif self.current_preset_name is None: self.preset_state_label.set_text("Unsaved") self.preset_state_label.add_css_class("preset-state-unsaved") self.preset_state_label.set_tooltip_text("Current curve has not been saved as a preset") @@ -244,6 +283,7 @@ def save_current_state_to_preset(self, name: str) -> None: write_mini_eq_preset_file(preset_path_for_name(preset_name), payload) self.current_preset_name = preset_name self.saved_preset_signature = self.controller.state_signature() + self.set_curve_revert_baseline(preset_name) self.output_preset_curve_auto_loaded = False self.refresh_preset_list() self.sync_ui_from_state() @@ -266,6 +306,7 @@ def load_library_preset( self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands)) self.current_preset_name = preset_name self.saved_preset_signature = self.controller.state_signature() + self.set_curve_revert_baseline(preset_name) self.refresh_preset_list() self.sync_ui_from_state() self.output_preset_curve_auto_loaded = bool(auto) @@ -326,6 +367,8 @@ def apply_output_preset_for_current_output( if reset_auto_preset_without_link: self.controller.reset_state() self.current_preset_name = None + self.saved_preset_signature = self.controller.state_signature() + self.set_curve_revert_baseline("Neutral") self.selected_band_index = None self.set_visible_band_count(DEFAULT_ACTIVE_BANDS) self.output_preset_curve_auto_loaded = False @@ -465,14 +508,32 @@ def on_preset_save_as_clicked(self, button: Gtk.Button) -> None: self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset) def on_preset_revert_clicked(self, button: Gtk.Button) -> None: - if self.current_preset_name is None: - self.set_status("No Preset Selected") + if self.current_preset_name is not None: + preset_name = self.current_preset_name + try: + self.load_library_preset(preset_name) + self.set_status(f"Reverted to Preset: {preset_name}") + except Exception as exc: + self.set_status(str(exc)) return - preset_name = self.current_preset_name + payload = getattr(self, "curve_revert_baseline_payload", None) + if payload is None: + self.set_status("No Curve Baseline") + return + + baseline_label = self.curve_revert_label() or "Curve Baseline" try: - self.load_library_preset(preset_name) - self.set_status(f"Reverted to Preset: {preset_name}") + self.controller.apply_preset_payload(payload) + self.current_preset_name = None + self.saved_preset_signature = self.controller.state_signature() + self.output_preset_auto_applied = False + self.output_preset_curve_auto_loaded = False + self.selected_band_index = None + self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands)) + self.sync_ui_from_state() + self.set_status(f"Reverted to {baseline_label}") + self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) @@ -585,6 +646,7 @@ def on_preset_delete_dialog_done( delete_preset_file(preset_name) self.current_preset_name = None self.saved_preset_signature = self.controller.state_signature() + self.clear_curve_revert_baseline() self.refresh_preset_list() self.sync_ui_from_state() self.set_status(f"Deleted Preset: {preset_name}") @@ -630,6 +692,7 @@ def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands)) self.current_preset_name = preset_name self.saved_preset_signature = self.controller.state_signature() + self.set_curve_revert_baseline(preset_name) self.refresh_preset_list() self.sync_ui_from_state() self.set_status(f"Imported Preset: {preset_name}") diff --git a/src/mini_eq/wireplumber_backend.py b/src/mini_eq/wireplumber_backend.py deleted file mode 100644 index 644abba..0000000 --- a/src/mini_eq/wireplumber_backend.py +++ /dev/null @@ -1,619 +0,0 @@ -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from typing import Any - -DEFAULT_METADATA_NAME = "default" -DEFAULT_AUDIO_SINK_KEY = "default.audio.sink" -DEFAULT_CONFIGURED_AUDIO_SINK_KEY = "default.configured.audio.sink" -TARGET_OBJECT_KEY = "target.object" -TARGET_NODE_KEY = "target.node" -SPA_ID_TYPE = "Spa:Id" -STREAM_OUTPUT_AUDIO = "Stream/Output/Audio" -AUDIO_SINK = "Audio/Sink" -FILTER_CHAIN_MODULE_NAME = "libpipewire-module-filter-chain" -PIPEWIRE_CLIENT_NAME = "Mini EQ" -PIPEWIRE_MEDIA_CATEGORY = "Manager" - - -@dataclass(frozen=True) -class WirePlumberNode: - bound_id: int - object_serial: str | None - media_class: str | None - node_name: str | None - node_description: str | None - application_name: str | None - node_dont_move: bool - properties: dict[str, str] = field(default_factory=dict) - - @property - def is_audio_sink(self) -> bool: - return self.media_class == AUDIO_SINK - - @property - def is_output_stream(self) -> bool: - return self.media_class == STREAM_OUTPUT_AUDIO - - @property - def display_name(self) -> str: - return self.node_description or self.application_name or self.node_name or f"node {self.bound_id}" - - def property_value(self, key: str, default: str = "") -> str: - return self.properties.get(key, default) - - -@dataclass(frozen=True) -class WirePlumberDefaults: - default_audio_sink: str | None - configured_audio_sink: str | None - - -class WirePlumberError(RuntimeError): - pass - - -def build_spa_params_pod(Wp, controls: dict[str, float]): - inner = Wp.SpaPodBuilder.new_struct() - for name, value in controls.items(): - inner.add_string(name) - inner.add_float(float(value)) - - inner_pod = inner.end() - outer = Wp.SpaPodBuilder.new_object("Spa:Pod:Object:Param:Props", "Props") - outer.add_property("params") - outer.add_pod(inner_pod) - return outer.end() - - -def parse_metadata_node_name(value: str | None) -> str | None: - if not value: - return None - - try: - payload = json.loads(value) - except json.JSONDecodeError: - return value - - if not isinstance(payload, dict): - return None - - name = payload.get("name") - return str(name) if name else None - - -def parse_bool_property(value: str | None) -> bool: - return str(value).lower() in {"1", "true", "yes", "on"} - - -def parse_positive_int(value: str | None) -> int: - try: - parsed = int(value or "") - except (TypeError, ValueError): - return 0 - - return parsed if parsed > 0 else 0 - - -def parse_rate_from_latency(value: str | None) -> int: - if not value or "/" not in value: - return 0 - - _frames, rate = value.rsplit("/", 1) - return parse_positive_int(rate) - - -def node_sample_rate(node: WirePlumberNode | None) -> float: - if node is None: - return 0.0 - - rate = parse_positive_int(node.property_value("audio.rate")) - if rate <= 0: - rate = parse_rate_from_latency(node.property_value("node.max-latency")) - if rate <= 0: - rate = parse_rate_from_latency(node.property_value("node.latency")) - - return float(rate) if rate > 0 else 0.0 - - -class WirePlumberBackend: - def __init__(self, timeout_ms: int = 2000) -> None: - self.timeout_ms = timeout_ms - self._connected = False - self._GLib: Any = None - self._GObject: Any = None - self._Wp: Any = None - self._core: Any = None - self._node_manager: Any = None - self._metadata_manager: Any = None - self._metadata_signal_objects: dict[int, Any] = {} - self._cached_defaults = WirePlumberDefaults(None, None) - - def __enter__(self) -> WirePlumberBackend: - self.connect() - return self - - def __exit__(self, _exc_type, _exc, _tb) -> None: - self.close() - - def connect(self) -> None: - if self._connected: - return - - GLib, GObject, Wp = self._import_wireplumber() - self._GLib = GLib - self._GObject = GObject - self._Wp = Wp - - init_flags = Wp.InitFlags.PIPEWIRE | Wp.InitFlags.SPA_TYPES - Wp.init(init_flags) - - self._core = self._new_core(Wp) - self._node_manager = self._build_node_manager(Wp) - self._metadata_manager = self._build_metadata_manager(Wp) - - pending = {"core", "nodes", "metadata"} - errors: list[BaseException] = [] - loop = GLib.MainLoop() - init_signal_handlers: list[tuple[Any, int]] = [] - timeout_id = 0 - - def mark_ready(name: str) -> None: - pending.discard(name) - if not pending: - loop.quit() - - def on_connected(_core) -> None: - mark_ready("core") - - def on_installed(_manager, name: str) -> None: - mark_ready(name) - - def on_timeout() -> bool: - loop.quit() - return False - - def safe_callback(callback): - def wrapper(*args): - try: - callback(*args) - except BaseException as exc: - errors.append(exc) - loop.quit() - - return wrapper - - init_signal_handlers.append( - (self._core, GObject.Object.connect(self._core, "connected", safe_callback(on_connected))) - ) - init_signal_handlers.append( - ( - self._node_manager, - GObject.Object.connect( - self._node_manager, "installed", safe_callback(lambda manager: on_installed(manager, "nodes")) - ), - ) - ) - init_signal_handlers.append( - ( - self._metadata_manager, - GObject.Object.connect( - self._metadata_manager, - "installed", - safe_callback(lambda manager: on_installed(manager, "metadata")), - ), - ) - ) - - try: - self._core.install_object_manager(self._node_manager) - self._core.install_object_manager(self._metadata_manager) - - if not self._core.connect(): - raise WirePlumberError("failed to connect to PipeWire through WirePlumber") - - timeout_id = GLib.timeout_add(self.timeout_ms, on_timeout) - loop.run() - finally: - if timeout_id > 0: - source = GLib.MainContext.default().find_source_by_id(timeout_id) - if source is not None: - source.destroy() - for obj, handler_id in init_signal_handlers: - try: - obj.disconnect(handler_id) - except Exception: - pass - - if errors: - raise WirePlumberError(f"WirePlumber initialization failed: {errors[0]}") from errors[0] - - if pending: - missing = ", ".join(sorted(pending)) - raise WirePlumberError(f"WirePlumber initialization timed out waiting for: {missing}") - - self._connected = True - - def close(self) -> None: - for handler_id, metadata in list(self._metadata_signal_objects.items()): - try: - metadata.disconnect(handler_id) - except Exception: - pass - self._metadata_signal_objects.clear() - - if self._core is not None: - try: - self._core.disconnect() - except Exception: - pass - - self._connected = False - self._core = None - self._node_manager = None - self._metadata_manager = None - self._cached_defaults = WirePlumberDefaults(None, None) - - def list_nodes(self) -> list[WirePlumberNode]: - self._ensure_connected() - nodes: list[WirePlumberNode] = [] - - for node in self._iterate_manager(self._node_manager): - try: - nodes.append(self._node_from_proxy(node)) - except UnicodeDecodeError: - continue - - return nodes - - def list_audio_sinks(self) -> list[WirePlumberNode]: - return [node for node in self.list_nodes() if node.is_audio_sink] - - def list_output_streams(self) -> list[WirePlumberNode]: - return [node for node in self.list_nodes() if node.is_output_stream] - - def node_from_proxy(self, node) -> WirePlumberNode: - return self._node_from_proxy(node) - - def connect_object_added(self, callback) -> int: - self._ensure_connected() - return self._GObject.Object.connect(self._node_manager, "object-added", callback) - - def connect_object_removed(self, callback) -> int: - self._ensure_connected() - return self._GObject.Object.connect(self._node_manager, "object-removed", callback) - - def disconnect_node_manager_handler(self, handler_id: int) -> None: - if self._node_manager is not None and handler_id > 0: - self._node_manager.disconnect(handler_id) - - def connect_metadata_changed(self, callback) -> int: - metadata = self._default_metadata() - handler_id = self._GObject.Object.connect(metadata, "changed", callback) - self._metadata_signal_objects[handler_id] = metadata - return handler_id - - def disconnect_metadata_handler(self, handler_id: int) -> None: - if handler_id <= 0: - return - - metadata = self._metadata_signal_objects.pop(handler_id, None) - if metadata is None: - return - - try: - metadata.disconnect(handler_id) - except Exception: - pass - - def sync(self) -> None: - self._ensure_connected() - self._sync_core() - - def defaults(self) -> WirePlumberDefaults: - if self._has_cached_defaults(): - return self._cached_defaults - - return self.refresh_defaults() - - def refresh_defaults(self) -> WirePlumberDefaults: - try: - self._cached_defaults = self._read_defaults() - return self._cached_defaults - except UnicodeDecodeError: - try: - self._sync_core() - self._cached_defaults = self._read_defaults() - return self._cached_defaults - except UnicodeDecodeError as retry_exc: - if self._has_cached_defaults(): - return self._cached_defaults - raise WirePlumberError("WirePlumber metadata contains an undecodable default sink value") from retry_exc - except Exception as retry_exc: - if self._has_cached_defaults(): - return self._cached_defaults - raise WirePlumberError(f"failed to refresh WirePlumber defaults: {retry_exc}") from retry_exc - except Exception: - if self._has_cached_defaults(): - return self._cached_defaults - raise - - def remember_default_metadata_change(self, key: str, value: str | None) -> bool: - if key not in {DEFAULT_AUDIO_SINK_KEY, DEFAULT_CONFIGURED_AUDIO_SINK_KEY}: - return False - - node_name = parse_metadata_node_name(value) - if key == DEFAULT_AUDIO_SINK_KEY: - self._cached_defaults = WirePlumberDefaults(node_name, self._cached_defaults.configured_audio_sink) - else: - self._cached_defaults = WirePlumberDefaults(self._cached_defaults.default_audio_sink, node_name) - - return True - - def _read_defaults(self) -> WirePlumberDefaults: - metadata = self._default_metadata() - default_sink, _default_type = metadata.find(0, DEFAULT_AUDIO_SINK_KEY) - configured_sink, _configured_type = metadata.find(0, DEFAULT_CONFIGURED_AUDIO_SINK_KEY) - return WirePlumberDefaults( - default_audio_sink=parse_metadata_node_name(default_sink), - configured_audio_sink=parse_metadata_node_name(configured_sink), - ) - - def _has_cached_defaults(self) -> bool: - return bool(self._cached_defaults.default_audio_sink or self._cached_defaults.configured_audio_sink) - - def move_stream_to_target(self, stream_bound_id: int, target_node_name: str) -> None: - stream = self.output_stream_by_bound_id(stream_bound_id) - if stream is None: - raise WirePlumberError(f"output stream not found: {stream_bound_id}") - - if stream.node_dont_move: - raise WirePlumberError(f"stream is marked node.dont-move: {stream.display_name}") - - target = self.audio_sink_by_name(target_node_name) - if target is None: - raise WirePlumberError(f"audio sink not found: {target_node_name}") - - if not target.object_serial: - raise WirePlumberError(f"audio sink has no object.serial: {target_node_name}") - - self.set_stream_target(stream.bound_id, target.bound_id, target.object_serial) - - def set_stream_target(self, stream_bound_id: int, target_bound_id: int, target_serial: str) -> None: - metadata = self._default_metadata() - - # target.node keeps compatibility with older session-manager behavior, - # while target.object is the stable serial-based target used by modern - # WirePlumber. Write metadata and wait for a PipeWire round trip, - # without treating metadata readback as a separate acknowledgement - # protocol. - metadata.set(stream_bound_id, TARGET_NODE_KEY, SPA_ID_TYPE, str(target_bound_id)) - metadata.set(stream_bound_id, TARGET_OBJECT_KEY, SPA_ID_TYPE, target_serial) - self._sync_core() - - def output_stream_by_bound_id(self, bound_id: int) -> WirePlumberNode | None: - for stream in self.list_output_streams(): - if stream.bound_id == bound_id: - return stream - - return None - - def output_stream_by_name(self, node_name: str) -> WirePlumberNode | None: - for stream in self.list_output_streams(): - if stream.node_name == node_name: - return stream - - return None - - def move_named_output_stream_to_target(self, stream_node_name: str, target_node_name: str) -> None: - stream = self.output_stream_by_name(stream_node_name) - if stream is None: - raise WirePlumberError(f"output stream not found: {stream_node_name}") - - self.move_stream_to_target(stream.bound_id, target_node_name) - - def audio_sink_by_name(self, node_name: str) -> WirePlumberNode | None: - for sink in self.list_audio_sinks(): - if sink.node_name == node_name: - return sink - - return None - - def set_node_params(self, node_bound_id: int, controls: dict[str, float]) -> None: - self._ensure_connected() - - node = self._node_proxy_by_bound_id(node_bound_id) - if node is None: - raise WirePlumberError(f"node not found: {node_bound_id}") - - params_pod = build_spa_params_pod(self._Wp, controls) - if not node.set_param("Props", 0, params_pod): - raise WirePlumberError(f"failed to set node params: {node_bound_id}") - - def load_filter_chain_module(self, arguments: str): - self._ensure_connected() - - module = self._Wp.ImplModule.load(self._core, FILTER_CHAIN_MODULE_NAME, arguments, None) - if module is None: - raise WirePlumberError(f"failed to load PipeWire module: {FILTER_CHAIN_MODULE_NAME}") - - return module - - def _build_node_manager(self, Wp): - manager = Wp.ObjectManager.new() - manager.add_interest_full(Wp.ObjectInterest.new_type(Wp.Node)) - features = Wp.ProxyFeatures.PIPEWIRE_OBJECT_FEATURE_INFO | Wp.ProxyFeatures.PROXY_FEATURE_BOUND - manager.request_object_features(Wp.Node, features) - return manager - - @staticmethod - def _new_core(Wp): - properties = Wp.Properties.new_empty() - properties.set("application.name", PIPEWIRE_CLIENT_NAME) - properties.set("media.category", PIPEWIRE_MEDIA_CATEGORY) - - try: - return Wp.Core.new(None, None, properties) - except TypeError: - return Wp.Core.new(None, None) - - def _build_metadata_manager(self, Wp): - manager = Wp.ObjectManager.new() - manager.add_interest_full(Wp.ObjectInterest.new_type(Wp.Metadata)) - manager.request_object_features(Wp.Metadata, Wp.ProxyFeatures.PROXY_FEATURE_BOUND) - return manager - - def _default_metadata(self): - self._ensure_connected() - - for metadata in self._iterate_manager(self._metadata_manager): - props = metadata.get_global_properties() - if props.get("metadata.name") == DEFAULT_METADATA_NAME: - return metadata - - raise WirePlumberError("default WirePlumber metadata object not found") - - def _node_from_proxy(self, node) -> WirePlumberNode: - properties = self._properties_dict(node) - return WirePlumberNode( - bound_id=int(node.get_bound_id()), - object_serial=self._pw_property(node, "object.serial", properties), - media_class=self._pw_property(node, "media.class", properties), - node_name=self._pw_property(node, "node.name", properties), - node_description=self._pw_property(node, "node.description", properties), - application_name=self._pw_property(node, "application.name", properties), - node_dont_move=parse_bool_property(self._pw_property(node, "node.dont-move", properties)), - properties=properties, - ) - - def _node_proxy_by_bound_id(self, bound_id: int): - for node in self._iterate_manager(self._node_manager): - if int(node.get_bound_id()) == int(bound_id): - return node - - return None - - def _pw_property(self, proxy, key: str, properties: dict[str, str] | None = None) -> str | None: - if properties is not None and key in properties: - return properties[key] - - try: - value = self._Wp.PipewireObject.get_property(proxy, key) - if value is not None: - return str(value) - except (TypeError, UnicodeDecodeError): - pass - - try: - props = proxy.get_global_properties() - value = props.get(key) - return str(value) if value is not None else None - except Exception: - return None - - def _properties_dict(self, proxy) -> dict[str, str]: - try: - props = proxy.get_global_properties() - iterator = props.new_iterator() - except Exception: - return {} - - result: dict[str, str] = {} - - while True: - try: - ok, item = iterator.next() - except TypeError: - break - - if not ok or item is None: - break - - try: - key = item.get_key() - value = item.get_value() - key_text = str(key) if key is not None else None - value_text = str(value) if value is not None else None - except UnicodeDecodeError: - continue - - if key_text is not None and value_text is not None: - result[key_text] = value_text - - return result - - def _iterate_manager(self, manager) -> list[Any]: - iterator = manager.new_iterator() - items: list[Any] = [] - - while True: - try: - ok, item = iterator.next() - except TypeError: - break - - if not ok: - break - if item is not None: - items.append(item) - - return items - - def _ensure_connected(self) -> None: - if not self._connected: - self.connect() - - def _sync_core(self) -> None: - errors: list[BaseException] = [] - loop = self._GLib.MainLoop() - timeout_id = 0 - - def on_sync_done(core, result, _user_data=None) -> None: - try: - if not core.sync_finish(result): - errors.append(WirePlumberError("WirePlumber core sync failed")) - except BaseException as exc: - errors.append(exc) - finally: - loop.quit() - - def on_timeout() -> bool: - errors.append(WirePlumberError("WirePlumber core sync timed out")) - loop.quit() - return False - - if not self._core.sync(None, on_sync_done, None): - raise WirePlumberError("failed to start WirePlumber core sync") - - try: - timeout_id = self._GLib.timeout_add(self.timeout_ms, on_timeout) - loop.run() - finally: - if timeout_id > 0: - source = self._GLib.MainContext.default().find_source_by_id(timeout_id) - if source is not None: - source.destroy() - - if errors: - raise WirePlumberError(f"WirePlumber core sync failed: {errors[0]}") from errors[0] - - @staticmethod - def _import_wireplumber(): - import gi - - last_error: Exception | None = None - for version in ("0.5", "0.4"): - try: - gi.require_version("Wp", version) - break - except ValueError as exc: - last_error = exc - else: - if last_error is not None: - raise last_error - raise WirePlumberError("WirePlumber GI namespace is not available") - - from gi.repository import GLib, GObject, Wp - - return GLib, GObject, Wp diff --git a/tests/_mini_eq_imports.py b/tests/_mini_eq_imports.py index dd20fab..963fbb6 100644 --- a/tests/_mini_eq_imports.py +++ b/tests/_mini_eq_imports.py @@ -11,5 +11,5 @@ def import_mini_eq_module(name: str): filter_chain = import_mini_eq_module("filter_chain") routing = import_mini_eq_module("routing") instance = import_mini_eq_module("instance") -wireplumber_backend = import_mini_eq_module("wireplumber_backend") -wireplumber_stream_router = import_mini_eq_module("wireplumber_stream_router") +pipewire_backend = import_mini_eq_module("pipewire_backend") +pipewire_stream_router = import_mini_eq_module("pipewire_stream_router") diff --git a/tests/test_flathub_manifest_drift.py b/tests/test_flathub_manifest_drift.py new file mode 100644 index 0000000..4a1d5c4 --- /dev/null +++ b/tests/test_flathub_manifest_drift.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path + +from tools import check_flathub_manifest_drift + +UPSTREAM_MANIFEST = """app-id: io.github.bhack.mini-eq +runtime: org.gnome.Platform +runtime-version: "50" +sdk: org.gnome.Sdk +modules: + - name: python3-dependencies + - name: mini-eq + buildsystem: simple + sources: + - type: dir + path: . +""" + +FLATHUB_MANIFEST = """app-id: io.github.bhack.mini-eq +runtime: org.gnome.Platform +runtime-version: "50" +sdk: org.gnome.Sdk +modules: + - name: python3-dependencies + - name: mini-eq + buildsystem: simple + sources: + - type: archive + url: https://github.com/bhack/mini-eq/releases/download/v0.5.1/mini_eq-0.5.1.tar.gz + sha256: abc123 +""" + + +def write_manifest_tree(path: Path, manifest: str, dependencies: str) -> Path: + path.mkdir() + manifest_path = path / "io.github.bhack.mini-eq.yaml" + manifest_path.write_text(manifest, encoding="utf-8") + (path / "python3-dependencies.yaml").write_text(dependencies, encoding="utf-8") + return manifest_path + + +def test_flathub_drift_allows_only_mini_eq_source_difference(tmp_path: Path) -> None: + dependencies = "name: python3-dependencies\nmodules: []\n" + upstream_manifest = write_manifest_tree(tmp_path / "upstream", UPSTREAM_MANIFEST, dependencies) + flathub_manifest = write_manifest_tree(tmp_path / "flathub", FLATHUB_MANIFEST, dependencies) + + result = check_flathub_manifest_drift.main([str(upstream_manifest), str(flathub_manifest)]) + + assert result == 0 + + +def test_flathub_drift_detects_python_dependency_manifest_difference(tmp_path: Path, capsys) -> None: + upstream_manifest = write_manifest_tree( + tmp_path / "upstream", + UPSTREAM_MANIFEST, + "name: python3-dependencies\nmodules: []\n", + ) + flathub_manifest = write_manifest_tree( + tmp_path / "flathub", + FLATHUB_MANIFEST, + "name: python3-dependencies\nmodules:\n - name: python3-JACK-Client\n", + ) + + result = check_flathub_manifest_drift.main([str(upstream_manifest), str(flathub_manifest)]) + + assert result == 1 + assert "python3-dependencies.yaml" in capsys.readouterr().out diff --git a/tests/test_mini_eq_analyzer.py b/tests/test_mini_eq_analyzer.py index 42f9493..b0c28ba 100644 --- a/tests/test_mini_eq_analyzer.py +++ b/tests/test_mini_eq_analyzer.py @@ -1,8 +1,6 @@ from __future__ import annotations import math -import os -import sys from array import array import pytest @@ -234,171 +232,152 @@ def test_stereo_f32le_bytes_to_interleaved_float32_preserves_channels() -> None: assert decoded.tolist() == pytest.approx([1.0, 0.0, 0.5, -0.5, -0.25, 0.25]) -class FakeJackPort: - def __init__(self, name: str) -> None: - self.name = name - self.shortname = name.rsplit(":", 1)[-1] +def test_interleaved_f32le_bytes_to_channel_payloads_splits_stereo() -> None: + interleaved = array("f", [1.0, 0.0, 0.5, -0.5, -0.25, 0.25]) + left, right = analyzer.interleaved_f32le_bytes_to_channel_payloads(interleaved.tobytes(), 2) -def test_jack_audio_output_ports_for_sink_matches_description() -> None: - ports = [ - FakeJackPort("Other Sink:monitor_FL"), - FakeJackPort("Test Sink:monitor_FL"), - FakeJackPort("Test Sink:monitor_FR"), - ] + assert list(analyzer.pcm_f32le_bytes_to_samples(left)) == pytest.approx([1.0, 0.5, -0.25]) + assert list(analyzer.pcm_f32le_bytes_to_samples(right)) == pytest.approx([0.0, -0.5, 0.25]) - selected = analyzer.jack_audio_output_ports_for_sink(ports, "alsa_output.test", "Test Sink") - assert [port.name for port in selected] == ["Test Sink:monitor_FL", "Test Sink:monitor_FR"] +def test_interleaved_f32le_bytes_to_channel_payloads_duplicates_mono() -> None: + mono = array("f", [0.25, -0.25]) + left, right = analyzer.interleaved_f32le_bytes_to_channel_payloads(mono.tobytes(), 1) -def test_jack_pipewire_props_marks_analyzer_as_monitor() -> None: - props = analyzer.jack_pipewire_props("node.latency = 512/48000") + assert left == mono.tobytes() + assert right == mono.tobytes() - assert "node.latency = 512/48000" in props - assert "node.autoconnect = false" in props - assert "stream.monitor = true" in props - assert "media.category = Monitor" in props +class FakePwgStream: + def __init__(self, target_object: str | None, monitor: bool) -> None: + self.target_object = target_object + self.monitor = monitor + self.requested_format = None + self.pipewire_properties: dict[str, str] = {} + self.deliver_audio_blocks = False + self.signal_handlers: list[tuple[str, object]] = [] + self.disconnected: list[int] = [] + self.start_count = 0 + self.stop_count = 0 + self.rate = 44100 -def test_jack_audio_output_ports_for_sink_matches_description_with_pipewire_suffix() -> None: - ports = [ - FakeJackPort("Test Sink-114:monitor_MONO"), - FakeJackPort("Bluetooth internal capture stream for Test Sink:monitor_MONO"), - ] + def set_requested_format(self, sample_format: str, rate: int, channels: int) -> None: + self.requested_format = (sample_format, rate, channels) - selected = analyzer.jack_audio_output_ports_for_sink(ports, "bluez_output.test", "Test Sink") + def set_pipewire_property(self, key: str, value: str) -> None: + self.pipewire_properties[key] = value - assert [port.name for port in selected] == ["Test Sink-114:monitor_MONO"] + def set_deliver_audio_blocks(self, deliver_audio_blocks: bool) -> None: + self.deliver_audio_blocks = deliver_audio_blocks + def connect(self, signal_name: str, callback) -> int: + self.signal_handlers.append((signal_name, callback)) + return len(self.signal_handlers) -def test_select_jack_stereo_output_ports_prefers_monitor_pair() -> None: - ports = [ - FakeJackPort("Test Sink:playback_FL"), - FakeJackPort("Test Sink:monitor_FR"), - FakeJackPort("Test Sink:monitor_FL"), - ] + def disconnect(self, handler_id: int) -> None: + self.disconnected.append(handler_id) - left, right = analyzer.select_jack_stereo_output_ports(ports) + def start(self) -> bool: + self.start_count += 1 + return True - assert left.name == "Test Sink:monitor_FL" - assert right.name == "Test Sink:monitor_FR" + def stop(self) -> None: + self.stop_count += 1 + def get_requested_rate(self) -> int: + return self.requested_format[1] if self.requested_format is not None else analyzer.SAMPLE_RATE -def test_select_jack_stereo_output_ports_accepts_aux_monitor_pair() -> None: - ports = [ - FakeJackPort("Test Sink:monitor_AUX1"), - FakeJackPort("Test Sink:monitor_AUX0"), - ] + def get_rate(self) -> int: + return self.rate - left, right = analyzer.select_jack_stereo_output_ports(ports) - assert left.name == "Test Sink:monitor_AUX0" - assert right.name == "Test Sink:monitor_AUX1" +class FakePwg: + init_count = 0 + streams: list[FakePwgStream] = [] + class Stream: + @staticmethod + def new_audio_capture(target_object: str | None, monitor: bool) -> FakePwgStream: + stream = FakePwgStream(target_object, monitor) + FakePwg.streams.append(stream) + return stream -def test_select_jack_stereo_output_ports_ignores_capture_ports() -> None: - ports = [ - FakeJackPort("Test Sink:capture_MONO"), - FakeJackPort("Test Sink:capture_FL"), - FakeJackPort("Test Sink:capture_FR"), - ] + @staticmethod + def init() -> None: + FakePwg.init_count += 1 - left, right = analyzer.select_jack_stereo_output_ports(ports) - assert left is None - assert right is None - - -def test_disconnect_jack_input_port_connections_removes_autoconnections() -> None: - class FakeJackClient: - def __init__(self) -> None: - self.disconnected: list[tuple[str, str]] = [] - - def get_all_connections(self, port: str) -> list[str]: - return [f"source-for-{port}"] - - def disconnect(self, source: str, destination: str) -> None: - self.disconnected.append((source, destination)) - - client = FakeJackClient() - - analyzer.disconnect_jack_input_port_connections(client, ("input_FL", "input_FR", None)) - - assert client.disconnected == [ - ("source-for-input_FL", "input_FL"), - ("source-for-input_FR", "input_FR"), - ] - - -def test_enabled_analyzer_reconnects_existing_jack_client_on_output_change( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_open_pwg_stream_configures_monitor_capture(monkeypatch: pytest.MonkeyPatch) -> None: + FakePwg.init_count = 0 + FakePwg.streams = [] + monkeypatch.setattr( + analyzer.OutputSpectrumAnalyzer, + "_import_pipewire_gobject", + staticmethod(lambda: (object(), FakePwg)), + ) + spectrum = analyzer.OutputSpectrumAnalyzer("alsa_output.test", None, lambda _message: None) + + stream = spectrum.open_pwg_stream() + + assert stream is FakePwg.streams[0] + assert FakePwg.init_count == 1 + assert stream.target_object == "alsa_output.test" + assert stream.monitor is True + assert stream.pipewire_properties["node.name"] == "mini-eq-analyzer" + assert stream.pipewire_properties["application.name"] == "Mini EQ" + assert stream.pipewire_properties["media.class"] == analyzer.ANALYZER_MEDIA_CLASS + assert stream.pipewire_properties["media.category"] == "Monitor" + assert stream.pipewire_properties["media.role"] == "DSP" + assert stream.pipewire_properties["node.dont-move"] == "true" + assert stream.pipewire_properties["stream.monitor"] == "true" + assert stream.pipewire_properties["state.restore-props"] == "false" + assert stream.pipewire_properties["state.restore-target"] == "false" + assert stream.requested_format == ("F32", analyzer.SAMPLE_RATE, 2) + assert stream.deliver_audio_blocks is True + assert stream.signal_handlers[0][0] == "audio-block" + assert spectrum.sample_rate == analyzer.SAMPLE_RATE + + +def test_enabled_analyzer_recreates_existing_pwg_stream_on_output_change() -> None: spectrum = analyzer.OutputSpectrumAnalyzer("old-sink", None, lambda _message: None) spectrum.enabled = True - spectrum.client = object() - spectrum.left_input_port = "input_FL" - spectrum.right_input_port = "input_FR" - calls: list[tuple[str, object]] = [] + spectrum.stream = object() + calls: list[tuple[str, dict[str, object]]] = [] - monkeypatch.setattr( - analyzer, - "disconnect_jack_input_port_connections", - lambda client, ports: calls.append(("disconnect", ports)), - ) - spectrum.connect_jack_monitor_ports = lambda client: calls.append(("connect", client)) + def stop(**kwargs) -> None: + calls.append(("stop", kwargs)) + spectrum.stream = None + + def restart() -> bool: + calls.append(("restart", {})) + return True + + spectrum.stop = stop + spectrum.restart = restart spectrum.set_output_sink_name("new-sink", "New Sink") assert spectrum.output_sink_name == "new-sink" assert spectrum.output_sink_description == "New Sink" - assert calls == [ - ("disconnect", ("input_FL", "input_FR")), - ("connect", spectrum.client), - ] - - -def test_open_jack_client_sets_pipewire_props_temporarily(monkeypatch: pytest.MonkeyPatch) -> None: - opened_with_props: list[str | None] = [] + assert calls == [("stop", {"close_stream": True}), ("restart", {})] - class FakeJackClient: - def __init__(self, _name: str, no_start_server: bool) -> None: - assert no_start_server is True - opened_with_props.append(os.environ.get("PIPEWIRE_PROPS")) - self.samplerate = 44100 - class FakeJackModule: - Client = FakeJackClient - - monkeypatch.setitem(sys.modules, "jack", FakeJackModule) - monkeypatch.setenv("PIPEWIRE_PROPS", "node.latency = 512/48000") +def test_prepare_opens_pwg_stream_without_start() -> None: spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None) - - client = spectrum.open_jack_client() - - assert client.samplerate == 44100 - assert opened_with_props - assert "node.latency = 512/48000" in opened_with_props[0] - assert "node.autoconnect = false" in opened_with_props[0] - assert "stream.monitor = true" in opened_with_props[0] - assert os.environ["PIPEWIRE_PROPS"] == "node.latency = 512/48000" - - -def test_prepare_opens_jack_client_without_activating_ports() -> None: - spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None) - client = object() + stream = FakePwgStream("test_sink", True) calls: list[str] = [] - def open_client(): + def open_stream(): calls.append("open") - return client + return stream - spectrum.open_jack_client = open_client + spectrum.open_pwg_stream = open_stream assert spectrum.prepare() is True - assert spectrum.client is client - assert spectrum.left_input_port is None - assert spectrum.right_input_port is None + assert spectrum.stream is stream + assert stream.start_count == 0 assert calls == ["open"] assert spectrum.prepare() is True @@ -420,54 +399,64 @@ def join(self, timeout: float) -> None: assert join_timeouts == [analyzer.ANALYZER_READER_JOIN_TIMEOUT_SECONDS] -def test_close_deactivates_jack_client_once_before_close() -> None: +def test_close_stops_pwg_stream_and_disconnects_signal() -> None: spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None) - calls: list[str] = [] + stream = FakePwgStream("test_sink", True) + spectrum.stream = stream + spectrum.stream_active = True + spectrum.stream_signal_handler_ids = [7] - class FakeClient: - def deactivate(self) -> None: - calls.append("deactivate") + spectrum.close() - def close(self) -> None: - calls.append("close") + assert stream.stop_count >= 1 + assert stream.disconnected == [7] + assert spectrum.stream is None + assert spectrum.stream_active is False - spectrum.client = FakeClient() - spectrum.client_active = True - spectrum.close() +def test_activate_pwg_stream_starts_stream_and_updates_sample_rate() -> None: + spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None) + stream = FakePwgStream("test_sink", True) - assert calls == ["deactivate", "close"] - assert spectrum.client is None - assert spectrum.client_active is False + spectrum.activate_pwg_stream(stream) + assert stream.start_count == 1 + assert spectrum.stream_active is True + assert spectrum.sample_rate == 44100.0 -def test_analyzer_registers_terminal_jack_input_ports(monkeypatch: pytest.MonkeyPatch) -> None: - registered: list[tuple[str, bool]] = [] - class FakeInputPorts: - def register(self, name: str, is_terminal: bool = False): - registered.append((name, is_terminal)) - return name +def test_process_audio_block_queues_interleaved_audio() -> None: + spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None) + spectrum.stop_event.clear() + payload = array("f", [1.0, 0.0, 0.5, -0.5]).tobytes() - class FakeJackClient: - inports = FakeInputPorts() + class FakeFormat: + def get_sample_format(self) -> str: + return "F32" - def set_process_callback(self, _callback) -> None: - pass + def get_rate(self) -> int: + return 44100 - def activate(self) -> None: - pass + def get_channels(self) -> int: + return 2 - spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None) - spectrum.connect_jack_monitor_ports = lambda _client: None - monkeypatch.setattr(analyzer, "disconnect_jack_input_port_connections", lambda _client, _ports: None) + class FakeBytes: + def get_data(self) -> bytes: + return payload + + class FakeBlock: + def get_format(self) -> FakeFormat: + return FakeFormat() + + def get_data(self) -> FakeBytes: + return FakeBytes() - spectrum.activate_jack_client(FakeJackClient()) + spectrum.process_audio_block(None, FakeBlock()) - assert registered == [ - (analyzer.JACK_LEFT_INPUT_PORT, True), - (analyzer.JACK_RIGHT_INPUT_PORT, True), - ] + left, right = spectrum.audio_blocks.pop() + assert list(analyzer.pcm_f32le_bytes_to_samples(left)) == pytest.approx([1.0, 0.5]) + assert list(analyzer.pcm_f32le_bytes_to_samples(right)) == pytest.approx([0.0, -0.5]) + assert spectrum.sample_rate == 44100.0 def test_analyzer_feeds_loudness_meter_with_interleaved_stereo() -> None: @@ -589,7 +578,7 @@ def levels_callback(_levels: list[float]) -> None: monkeypatch.setattr(analyzer, "spectrum_db_values_to_levels", lambda _db_values: [0.5]) spectrum.set_levels_callback(levels_callback) - spectrum.read_jack_levels() + spectrum.read_audio_levels() assert snapshots == [analyzer.AnalyzerLoudnessSnapshot(-20.0, -18.0, -17.0)] assert len(created_meters) == 1 diff --git a/tests/test_mini_eq_atspi_widgets.py b/tests/test_mini_eq_atspi_widgets.py new file mode 100644 index 0000000..4d2e233 --- /dev/null +++ b/tests/test_mini_eq_atspi_widgets.py @@ -0,0 +1,481 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +if os.environ.get("MINI_EQ_RUN_ATSPI") != "1": + pytestmark = pytest.mark.skip(reason="set MINI_EQ_RUN_ATSPI=1 to run nested AT-SPI widget checks") + +HELPER_SKIP_EXIT_CODE = 77 + +NESTED_ATSPI_HELPER = r""" +import os +import shutil +import subprocess +import sys +import threading +import time +from pathlib import Path + +try: + import pyatspi +except Exception as exc: + print(f"pyatspi unavailable: {exc}") + raise SystemExit(77) + +APP_FRAME_NAME = "Mini EQ" +WAIT_TIMEOUT_SECONDS = 20.0 +SOCKET_POLL_INTERVAL_SECONDS = 0.1 +WAIT_EVENT_NAMES = ( + "window", + "object:children-changed", + "object:property-change", + "object:state-changed", +) + +repo_root = Path(os.environ["MINI_EQ_TEST_REPO_ROOT"]) +config_dir = Path(sys.argv[1]) +runtime_dir = Path(os.environ["XDG_RUNTIME_DIR"]) +wayland_name = sys.argv[2] +shell_log_path = Path(sys.argv[3]) +app_log_path = Path(sys.argv[4]) + + +def require_tool(name): + path = shutil.which(name) + if path is None: + print(f"{name} unavailable") + raise SystemExit(77) + return path + + +def terminate_process(process): + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2.0) + + +def wait_for_wayland_socket(shell_process): + socket_path = runtime_dir / wayland_name + deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if socket_path.is_socket(): + return + if shell_process.poll() is not None: + raise AssertionError(f"nested GNOME Shell exited early:\n{shell_log_path.read_text(errors='replace')}") + time.sleep(SOCKET_POLL_INTERVAL_SECONDS) + raise AssertionError(f"nested GNOME Shell did not create {socket_path}:\n{shell_log_path.read_text(errors='replace')}") + + +def iter_accessibles(root): + stack = [root] + visited = 0 + while stack and visited < 5000: + node = stack.pop() + visited += 1 + yield node + + try: + child_count = node.childCount + except Exception: + child_count = 0 + + for index in reversed(range(min(child_count, 600))): + try: + stack.append(node.getChildAtIndex(index)) + except Exception: + continue + + +def accessible_name(node): + try: + return node.name or "" + except Exception: + return "" + + +def accessible_role(node): + try: + return node.getRoleName() + except Exception: + return "" + + +def state_contains(node, state): + try: + return node.getState().contains(state) + except Exception: + return False + + +def find_accessible(root, *, name, role=None, showing=None): + for node in iter_accessibles(root): + if accessible_name(node) != name: + continue + if role is not None and accessible_role(node) != role: + continue + if showing is not None and state_contains(node, pyatspi.STATE_SHOWING) != showing: + continue + return node + return None + + +def find_accessible_with_roles(root, *, name, roles, showing=None): + for node in iter_accessibles(root): + if accessible_name(node) != name: + continue + if accessible_role(node) not in roles: + continue + if showing is not None and state_contains(node, pyatspi.STATE_SHOWING) != showing: + continue + return node + return None + + +def snapshot_frames(root): + rows = [] + for node in iter_accessibles(root): + role = accessible_role(node) + if role in {"application", "frame"}: + rows.append((role, accessible_name(node), state_contains(node, pyatspi.STATE_SHOWING))) + return rows + + +ACCESSIBLE_EVENT = threading.Event() + + +def on_accessible_event(_event): + ACCESSIBLE_EVENT.set() + + +def start_accessible_event_loop(): + pyatspi.Registry.registerEventListener(on_accessible_event, *WAIT_EVENT_NAMES) + event_thread = threading.Thread(target=pyatspi.Registry.start, name="mini-eq-atspi-events", daemon=True) + event_thread.start() + return event_thread + + +def stop_accessible_event_loop(event_thread): + try: + pyatspi.Registry.deregisterEventListener(on_accessible_event, *WAIT_EVENT_NAMES) + except Exception: + pass + pyatspi.Registry.stop() + event_thread.join(timeout=2.0) + + +def wait_for(description, predicate): + deadline = time.monotonic() + WAIT_TIMEOUT_SECONDS + + def timeout_error(): + desktop = pyatspi.Registry.getDesktop(0) + return AssertionError( + f"Timed out waiting for {description}; frames: {snapshot_frames(desktop)!r}\n" + f"Mini EQ log:\n{app_log_path.read_text(errors='replace')}\n" + f"Shell log:\n{shell_log_path.read_text(errors='replace')}" + ) + + while True: + value = predicate() + if value is not None and value is not False: + return value + + if app_process is not None and app_process.poll() is not None: + raise AssertionError( + f"Mini EQ exited while waiting for {description}:\n{app_log_path.read_text(errors='replace')}" + ) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise timeout_error() + + ACCESSIBLE_EVENT.wait(remaining) + ACCESSIBLE_EVENT.clear() + + +def checked(node): + return state_contains(node, pyatspi.STATE_CHECKED) + + +def sensitive(node): + return state_contains(node, pyatspi.STATE_SENSITIVE) + + +def visible_switch_with_state(root, *, name, expected_checked): + node = find_accessible(root, name=name, role="switch", showing=True) + if node is None or checked(node) != expected_checked: + return None + return node + + +def monitor_is_enabled(frame): + node = visible_switch_with_state(frame, name="Monitor", expected_checked=True) + if node is None: + return None + if find_accessible(frame, name="--", role="status bar", showing=True) is None: + return None + return node + + +def run_accessible_action(node, action_names): + try: + action = node.queryAction() + except Exception as exc: + raise AssertionError(f"{accessible_name(node)!r} does not expose AT-SPI actions") from exc + + exposed_action_names = [] + for index in range(action.nActions): + name = action.getName(index) + exposed_action_names.append(name) + if name not in action_names: + continue + if not action.doAction(index): + raise AssertionError(f"AT-SPI {name!r} action failed for {accessible_name(node)!r}") + return + + raise AssertionError( + f"{accessible_name(node)!r} does not expose one of {action_names!r}: {exposed_action_names!r}" + ) + + +def activate_control(node): + run_accessible_action(node, ("press", "click", "activate", "toggle")) + + +def toggle_switch(node): + run_accessible_action(node, ("toggle",)) + + +gnome_shell = require_tool("gnome-shell") +shell_log = shell_log_path.open("w", encoding="utf-8") +app_log = app_log_path.open("w", encoding="utf-8") +shell_process = None +app_process = None +atspi_event_thread = None + +try: + shell_process = subprocess.Popen( + [ + gnome_shell, + "--headless", + "--wayland", + "--no-x11", + "--virtual-monitor", + "1600x900", + "--wayland-display", + wayland_name, + ], + stdout=shell_log, + stderr=subprocess.STDOUT, + text=True, + ) + wait_for_wayland_socket(shell_process) + + app_env = os.environ.copy() + src_path = str(repo_root / "src") + app_env["PYTHONPATH"] = f"{src_path}{os.pathsep}{app_env['PYTHONPATH']}" if app_env.get("PYTHONPATH") else src_path + app_env["XDG_CONFIG_HOME"] = str(config_dir) + app_env["GSETTINGS_BACKEND"] = "memory" + app_env["GTK_A11Y"] = "atspi" + app_env["GDK_BACKEND"] = "wayland" + app_env["WAYLAND_DISPLAY"] = wayland_name + app_env.pop("DISPLAY", None) + + module_flag = "-" + "m" + module_name = "mini" + "_eq" + app_process = subprocess.Popen( + [sys.executable, module_flag, module_name], + cwd=repo_root, + env=app_env, + stdout=app_log, + stderr=subprocess.STDOUT, + text=True, + ) + atspi_event_thread = start_accessible_event_loop() + + time.sleep(1.5) + frame = wait_for( + "Mini EQ frame", + lambda: find_accessible( + pyatspi.Registry.getDesktop(0), + name=APP_FRAME_NAME, + role="frame", + showing=True, + ), + ) + desktop = pyatspi.Registry.getDesktop(0) + route_switch = wait_for( + "System-wide EQ switch", + lambda: find_accessible(frame, name="System-wide EQ", role="switch", showing=True), + ) + compare_switch = wait_for( + "Compare switch", + lambda: find_accessible(frame, name="Compare", role="switch", showing=True), + ) + monitor_switch = wait_for( + "Monitor switch", + lambda: find_accessible(frame, name="Monitor", role="switch", showing=True), + ) + + if not sensitive(route_switch): + raise AssertionError("System-wide EQ switch is not sensitive") + if checked(route_switch): + raise AssertionError("System-wide EQ switch unexpectedly starts checked") + if sensitive(compare_switch): + raise AssertionError("Compare switch should be insensitive while system routing is off") + if checked(monitor_switch): + raise AssertionError("Monitor switch should start unchecked from the temporary test config") + if find_accessible(frame, name="Not Applied", role="status bar", showing=True) is None: + raise AssertionError("Not Applied status is missing") + if find_accessible(frame, name="Off", role="status bar", showing=True) is None: + raise AssertionError("Monitor Off status is missing") + + toggle_switch(monitor_switch) + wait_for( + "Monitor switch to turn on", + lambda: monitor_is_enabled(frame), + ) + + settings_button = wait_for( + "Monitor Settings button", + lambda: find_accessible_with_roles( + frame, + name="Monitor Settings", + roles={"toggle button"}, + showing=True, + ), + ) + activate_control(settings_button) + freeze_switch = wait_for( + "Freeze Monitor switch", + lambda: find_accessible(desktop, name="Freeze Monitor", role="switch", showing=True), + ) + if not sensitive(freeze_switch): + raise AssertionError("Freeze Monitor switch should be sensitive while Monitor is on") + if checked(freeze_switch): + raise AssertionError("Freeze Monitor switch should start unchecked") + if find_accessible(desktop, name="Monitor Smoothing", role="slider", showing=True) is None: + raise AssertionError("Monitor Smoothing slider is missing from Monitor Settings") + if find_accessible(desktop, name="Monitor Display Gain", role="slider", showing=True) is None: + raise AssertionError("Monitor Display Gain slider is missing from Monitor Settings") + + toggle_switch(freeze_switch) + wait_for( + "Freeze Monitor switch to turn on", + lambda: visible_switch_with_state( + pyatspi.Registry.getDesktop(0), + name="Freeze Monitor", + expected_checked=True, + ), + ) + + freeze_switch = wait_for( + "Freeze Monitor switch after turning on", + lambda: find_accessible(desktop, name="Freeze Monitor", role="switch", showing=True), + ) + toggle_switch(freeze_switch) + wait_for( + "Freeze Monitor switch to turn off", + lambda: visible_switch_with_state( + pyatspi.Registry.getDesktop(0), + name="Freeze Monitor", + expected_checked=False, + ), + ) + + monitor_switch = wait_for( + "Monitor switch after turning on", + lambda: find_accessible(frame, name="Monitor", role="switch", showing=True), + ) + toggle_switch(monitor_switch) + wait_for( + "Monitor switch to turn off", + lambda: ( + visible_switch_with_state(frame, name="Monitor", expected_checked=False) + if find_accessible(frame, name="Off", role="status bar", showing=True) is not None + else None + ), + ) +finally: + if atspi_event_thread is not None: + stop_accessible_event_loop(atspi_event_thread) + if app_process is not None: + terminate_process(app_process) + if shell_process is not None: + terminate_process(shell_process) + shell_log.close() + app_log.close() +""" + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def write_test_settings(config_dir: Path) -> None: + settings_dir = config_dir / "mini-eq" + settings_dir.mkdir(parents=True) + (settings_dir / "settings.json").write_text( + json.dumps({"monitor_enabled": False, "background_mode": False}) + "\n", + encoding="utf-8", + ) + + +def run_nested_atspi_helper(tmp_path: Path) -> subprocess.CompletedProcess[str]: + if not shutil.which("dbus-run-session"): + pytest.skip("dbus-run-session is unavailable") + + config_dir = tmp_path / "config" + data_dir = tmp_path / "data" + cache_dir = tmp_path / "cache" + config_dir.mkdir() + data_dir.mkdir() + cache_dir.mkdir() + write_test_settings(config_dir) + + env = os.environ.copy() + env["XDG_CONFIG_HOME"] = str(config_dir) + env["XDG_DATA_HOME"] = str(data_dir) + env["XDG_CACHE_HOME"] = str(cache_dir) + env["GSETTINGS_BACKEND"] = "memory" + env["MINI_EQ_TEST_REPO_ROOT"] = str(repo_root()) + env.pop("WAYLAND_DISPLAY", None) + env.pop("DISPLAY", None) + + return subprocess.run( + [ + "dbus-run-session", + "--", + sys.executable, + "-c", + NESTED_ATSPI_HELPER, + str(config_dir), + f"mini-eq-atspi-{os.getpid()}", + str(tmp_path / "gnome-shell.log"), + str(tmp_path / "mini-eq.log"), + ], + cwd=repo_root(), + env=env, + capture_output=True, + text=True, + timeout=60.0, + check=False, + ) + + +def test_real_app_widgets_expose_and_update_accessible_state(tmp_path: Path) -> None: + result = run_nested_atspi_helper(tmp_path) + if result.returncode == HELPER_SKIP_EXIT_CODE: + pytest.skip(result.stdout.strip()) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_mini_eq_dbus_control.py b/tests/test_mini_eq_dbus_control.py index df26d40..f600832 100644 --- a/tests/test_mini_eq_dbus_control.py +++ b/tests/test_mini_eq_dbus_control.py @@ -52,6 +52,7 @@ def set_state(self, state: bool) -> None: class FakeConnection: def __init__(self) -> None: self.signals: list[tuple[str, object | None]] = [] + self.closed = False def emit_signal( self, @@ -63,6 +64,26 @@ def emit_signal( ) -> None: self.signals.append((signal_name, parameters)) + def is_closed(self) -> bool: + return self.closed + + +class ClosedErrorConnection(FakeConnection): + def emit_signal( + self, + _destination: str | None, + _object_path: str, + _interface_name: str, + signal_name: str, + parameters: object | None, + ) -> None: + del signal_name, parameters + raise dbus_control.GLib.Error( + "connection is closed", + dbus_control.Gio.io_error_quark(), + dbus_control.Gio.IOErrorEnum.CLOSED, + ) + class FakeWindow: def __init__(self, controller: FakeController) -> None: @@ -80,6 +101,51 @@ def __init__(self, controller: FakeController) -> None: self.output_preset_auto_applied = False self.visible = True + def sync_control_switches_from_controller(self, *, route: bool = True, eq: bool = True) -> None: + self.updating_ui = True + try: + if route: + self.route_switch.set_active(self.controller.routed) + self.route_switch.set_state(self.controller.routed) + if eq: + self.bypass_switch.set_active(self.controller.eq_enabled) + self.bypass_switch.set_state(self.controller.eq_enabled) + finally: + self.updating_ui = False + + def refresh_after_route_state_changed( + self, + *, + eq_was_enabled: bool, + announce_enabled: bool | None = None, + notify: bool = True, + ) -> None: + del announce_enabled, notify + self.sync_control_switches_from_controller() + self.update_eq_power_indicator() + self.update_info_label() + self.update_status_summary() + self.update_focus_summary() + if not eq_was_enabled and self.controller.eq_enabled: + self.invalidate_graph_response_cache() + self.queue_graph_draw() + self.update_preset_state() + + def refresh_after_eq_state_changed( + self, + *, + announce_enabled: bool | None = None, + notify: bool = True, + ) -> None: + del announce_enabled, notify + self.sync_control_switches_from_controller(route=False) + self.update_eq_power_indicator() + self.update_info_label() + self.update_status_summary() + self.invalidate_graph_response_cache() + self.queue_graph_draw() + self.update_preset_state() + def load_library_preset(self, name: str) -> None: self.current_preset_name = name self.loaded_presets.append(name) @@ -230,6 +296,34 @@ def test_dbus_control_emits_compact_analyzer_levels_signal() -> None: ) +def test_dbus_control_ignores_closed_connection_before_signal_emit() -> None: + control, _controller, window = make_control() + connection = FakeConnection() + connection.closed = True + control.connection = connection + control.registration_id = 12 + window.analyzer_enabled = True + + control.emit_analyzer_levels_changed() + + assert connection.signals == [] + assert control.connection is None + assert control.registration_id == 0 + + +def test_dbus_control_ignores_closed_connection_error_during_signal_emit() -> None: + control, _controller, window = make_control() + connection = ClosedErrorConnection() + control.connection = connection + control.registration_id = 12 + window.analyzer_enabled = True + + control.emit_analyzer_levels_changed() + + assert control.connection is None + assert control.registration_id == 0 + + def test_dbus_control_set_eq_enabled_updates_controller_and_window() -> None: control, controller, window = make_control() diff --git a/tests/test_mini_eq_deps.py b/tests/test_mini_eq_deps.py index fe3eeff..75b310a 100644 --- a/tests/test_mini_eq_deps.py +++ b/tests/test_mini_eq_deps.py @@ -36,16 +36,16 @@ def test_dependency_report_includes_hints_for_failed_checks() -> None: def test_first_available_gi_repository_accepts_later_version(monkeypatch) -> None: def fake_check(namespace: str, version: str, label: str, required: bool, hint: str) -> deps.DependencyCheck: - if version == "0.4": + if version == "1.0": return deps.DependencyCheck(label, "ok", required, f"GI namespace {namespace} {version}", hint) return deps.DependencyCheck(label, "missing", required, f"{namespace} {version} missing", hint) monkeypatch.setattr(deps, "check_gi_repository", fake_check) - check = deps.check_first_available_gi_repository("Wp", ("0.5", "0.4"), "WirePlumber GI namespace", True, "hint") + check = deps.check_first_available_gi_repository("Example", ("2.0", "1.0"), "Example GI namespace", True, "hint") assert check.ok - assert check.detail == "GI namespace Wp 0.4" + assert check.detail == "GI namespace Example 1.0" def test_first_available_gi_repository_reports_all_failures(monkeypatch) -> None: @@ -54,11 +54,11 @@ def fake_check(namespace: str, version: str, label: str, required: bool, hint: s monkeypatch.setattr(deps, "check_gi_repository", fake_check) - check = deps.check_first_available_gi_repository("Wp", ("0.5", "0.4"), "WirePlumber GI namespace", True, "hint") + check = deps.check_first_available_gi_repository("Example", ("2.0", "1.0"), "Example GI namespace", True, "hint") assert not check.ok - assert "Wp 0.5: Wp 0.5 missing" in check.detail - assert "Wp 0.4: Wp 0.4 missing" in check.detail + assert "Example 2.0: Example 2.0 missing" in check.detail + assert "Example 1.0: Example 1.0 missing" in check.detail def test_gi_repository_attribute_requires_named_attribute(monkeypatch) -> None: @@ -91,6 +91,58 @@ def fake_check(namespace: str, version: str, label: str, required: bool, hint: s assert check.detail == "GI namespace lacks Gtk.Button.set_can_shrink" +def test_pipewire_gobject_check_requires_current_library_version(monkeypatch) -> None: + fake_pwg = SimpleNamespace( + get_library_version=lambda: "0.3.2", + Core=SimpleNamespace(set_pipewire_property=object()), + Param=SimpleNamespace(new_props_controls=object()), + Stream=SimpleNamespace(set_pipewire_property=object()), + ) + + monkeypatch.setattr( + deps, + "check_python_import", + lambda _module, label, required, hint: deps.DependencyCheck(label, "ok", required, "shim ok", hint), + ) + monkeypatch.setattr( + deps, + "check_gi_repository", + lambda _namespace, _version, label, required, hint: deps.DependencyCheck(label, "ok", required, "Pwg ok", hint), + ) + monkeypatch.setattr(deps.importlib, "import_module", lambda _name: fake_pwg) + + check = deps.check_pipewire_gobject() + + assert not check.ok + assert "older than required 0.3.4" in check.detail + + +def test_pipewire_gobject_check_requires_property_override_symbols(monkeypatch) -> None: + fake_pwg = SimpleNamespace( + get_library_version=lambda: "0.3.4", + Core=SimpleNamespace(), + Param=SimpleNamespace(new_props_controls=object()), + Stream=SimpleNamespace(set_pipewire_property=object()), + ) + + monkeypatch.setattr( + deps, + "check_python_import", + lambda _module, label, required, hint: deps.DependencyCheck(label, "ok", required, "shim ok", hint), + ) + monkeypatch.setattr( + deps, + "check_gi_repository", + lambda _namespace, _version, label, required, hint: deps.DependencyCheck(label, "ok", required, "Pwg ok", hint), + ) + monkeypatch.setattr(deps.importlib, "import_module", lambda _name: fake_pwg) + + check = deps.check_pipewire_gobject() + + assert not check.ok + assert "Pwg.Core.set_pipewire_property" in check.detail + + def test_native_ebur128_check_is_optional_when_library_is_missing(monkeypatch) -> None: ebur128 = import_mini_eq_module("ebur128") diff --git a/tests/test_mini_eq_filter_chain.py b/tests/test_mini_eq_filter_chain.py index 34cff3b..7d9621e 100644 --- a/tests/test_mini_eq_filter_chain.py +++ b/tests/test_mini_eq_filter_chain.py @@ -36,6 +36,8 @@ def test_builtin_biquad_filter_chain_uses_pipewire_raw_biquads() -> None: assert 'inputs = [ "preamp_l:In" "preamp_r:In" ]' in args assert 'outputs = [ "band_l_1:Out" "band_r_1:Out" ]' in args assert 'target.object = "alsa_output.test"' in args + assert args.count("state.restore-props = false") == 2 + assert args.count("state.restore-target = false") == 2 def test_builtin_biquad_controls_cover_both_channels() -> None: diff --git a/tests/test_mini_eq_instance.py b/tests/test_mini_eq_instance.py index f5dffdf..7061030 100644 --- a/tests/test_mini_eq_instance.py +++ b/tests/test_mini_eq_instance.py @@ -10,6 +10,7 @@ def test_detects_mini_eq_python_cmdline() -> None: assert instance.is_mini_eq_python_cmdline(("python3", "-m", "mini_eq", "--auto-route")) assert instance.is_mini_eq_python_cmdline(("/usr/bin/python3", "/tmp/repo/.venv/bin/mini-eq")) + assert not instance.is_mini_eq_python_cmdline(("python3", "/tmp/tool.py", "--repo-root", "/home/user/mini-eq")) assert not instance.is_mini_eq_python_cmdline(("pytest", "tests/test_mini_eq_instance.py")) assert not instance.is_mini_eq_python_cmdline(("pipewire", "-c", "/tmp/mini-eq-a/filter-chain.conf")) diff --git a/tests/test_mini_eq_live_ui_runtime.py b/tests/test_mini_eq_live_ui_runtime.py new file mode 100644 index 0000000..0e3caee --- /dev/null +++ b/tests/test_mini_eq_live_ui_runtime.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +if os.environ.get("MINI_EQ_RUN_LIVE_UI") != "1": + pytestmark = pytest.mark.skip(reason="set MINI_EQ_RUN_LIVE_UI=1 to run live AT-SPI/PipeWire UI smoke") + +HELPER_SKIP_EXIT_CODE = 77 + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def test_live_ui_runtime_smoke_drives_real_app_with_synthetic_stream() -> None: + result = subprocess.run( + [ + sys.executable, + "tools/check_live_ui_runtime.py", + "--timeout", + "35", + "--cycles", + "2", + "--audio-duration", + "120", + ], + cwd=repo_root(), + capture_output=True, + text=True, + timeout=300.0, + check=False, + ) + if result.returncode == HELPER_SKIP_EXIT_CODE: + pytest.skip(result.stdout + result.stderr) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_mini_eq_output_presets.py b/tests/test_mini_eq_output_presets.py index 9bf6504..6485d5a 100644 --- a/tests/test_mini_eq_output_presets.py +++ b/tests/test_mini_eq_output_presets.py @@ -119,6 +119,7 @@ def __init__(self, controller) -> None: self.default_preset_clear_button = FakeButton() self.output_preset_switch = FakeSwitch() self.default_preset_state_label = FakeLabel() + self.set_curve_revert_baseline("Neutral") def set_visible_band_count(self, count: int) -> None: self.visible_band_count = count @@ -155,6 +156,79 @@ def write_test_preset(name: str, gain_db: float) -> None: core.write_mini_eq_preset_file(core.preset_path_for_name(name), payload) +def test_revert_action_explains_missing_loaded_preset() -> None: + controller = make_controller() + controller.bands[0].gain_db = 2.0 + test_window = OutputPresetWindow(controller) + test_window.clear_curve_revert_baseline() + + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.sensitive is False + assert test_window.preset_revert_button.tooltip == "Load or import a preset first" + + +def test_revert_action_tracks_initial_neutral_baseline() -> None: + controller = make_controller() + test_window = OutputPresetWindow(controller) + + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.sensitive is False + assert test_window.preset_revert_button.tooltip == "No curve changes to revert" + + controller.bands[0].gain_db = 2.0 + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.sensitive is True + assert test_window.preset_revert_button.tooltip == "Revert to Neutral" + + +def test_revert_action_updates_for_named_preset_changes() -> None: + controller = make_controller() + test_window = OutputPresetWindow(controller) + test_window.current_preset_name = "Headphones" + test_window.saved_preset_signature = controller.state_signature() + + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.sensitive is False + assert test_window.preset_revert_button.tooltip == "No curve changes to revert" + + controller.bands[0].gain_db = 2.0 + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.sensitive is True + assert test_window.preset_revert_button.tooltip == "Revert to Headphones" + + +def test_revert_action_tracks_unsaved_import_baseline() -> None: + controller = make_controller() + controller.bands[0].gain_db = 2.0 + test_window = OutputPresetWindow(controller) + test_window.set_curve_revert_baseline("Imported APO Preset") + + test_window.refresh_preset_actions() + + assert test_window.preset_revert_button.sensitive is False + assert test_window.preset_revert_button.tooltip == "No curve changes to revert" + + controller.bands[0].gain_db = 4.0 + test_window.update_preset_state() + + assert test_window.preset_state_label.text == "Modified" + assert test_window.preset_revert_button.sensitive is True + assert test_window.preset_revert_button.tooltip == "Revert to Imported APO Preset" + + test_window.on_preset_revert_clicked(FakeButton()) + test_window.update_preset_state() + + assert test_window.current_preset_name is None + assert controller.bands[0].gain_db == 2.0 + assert test_window.preset_revert_button.sensitive is False + assert test_window.statuses[-1] == "Reverted to Imported APO Preset" + + def test_initial_output_preset_auto_loads_linked_preset(monkeypatch, tmp_path) -> None: monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets") monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json") diff --git a/tests/test_mini_eq_wireplumber_backend.py b/tests/test_mini_eq_pipewire_backend.py similarity index 54% rename from tests/test_mini_eq_wireplumber_backend.py rename to tests/test_mini_eq_pipewire_backend.py index 5fff64e..61bf250 100644 --- a/tests/test_mini_eq_wireplumber_backend.py +++ b/tests/test_mini_eq_pipewire_backend.py @@ -2,81 +2,27 @@ import pytest -from tests._mini_eq_imports import wireplumber_backend as wp_backend +from tests._mini_eq_imports import pipewire_backend as pw_backend -class FakeSpaPodBuilder: - builders: list[FakeSpaPodBuilder] = [] - - def __init__(self, kind: str, args: tuple[str, ...] = ()) -> None: - self.kind = kind - self.args = args - self.calls: list[tuple[str, object]] = [] - FakeSpaPodBuilder.builders.append(self) - - @classmethod - def new_struct(cls) -> FakeSpaPodBuilder: - return cls("struct") - - @classmethod - def new_object(cls, type_name: str, id_name: str) -> FakeSpaPodBuilder: - return cls("object", (type_name, id_name)) - - def add_string(self, value: str) -> None: - self.calls.append(("string", value)) - - def add_float(self, value: float) -> None: - self.calls.append(("float", value)) - - def add_property(self, value: str) -> None: - self.calls.append(("property", value)) - - def add_pod(self, pod) -> None: - self.calls.append(("pod", pod)) - - def end(self): - return self - - -class FakeImplModule: - load_calls: list[tuple[object, str, str, object | None]] = [] - result: object | None = object() - - @classmethod - def load(cls, core, name: str, arguments: str, properties): - cls.load_calls.append((core, name, arguments, properties)) - return cls.result - - -class FakeWp: - SpaPodBuilder = FakeSpaPodBuilder - ImplModule = FakeImplModule - +class FakeCore: + calls: int = 0 -class FakeProperties: def __init__(self) -> None: - self.values: dict[str, str] = {} + self.pipewire_properties: dict[str, str | None] = {} @classmethod - def new_empty(cls) -> FakeProperties: + def new(cls): + cls.calls += 1 return cls() - def set(self, key: str, value: str) -> None: - self.values[key] = value - - -class FakeCore: - calls: list[tuple[object | None, object | None, FakeProperties | None]] = [] - - @classmethod - def new(cls, context, conf, properties=None): - cls.calls.append((context, conf, properties)) - return object() + def set_pipewire_property(self, key: str, value: str | None) -> bool: + self.pipewire_properties[key] = value + return True -class FakeCoreWp: +class FakeCorePwg: Core = FakeCore - Properties = FakeProperties class FakeNodeProxy: @@ -138,6 +84,9 @@ class FakePropertyProxy: def __init__(self, properties: FakeGlobalProperties) -> None: self.properties = properties + def get_properties(self) -> FakeGlobalProperties: + return self.properties + def get_global_properties(self) -> FakeGlobalProperties: return self.properties @@ -169,10 +118,19 @@ def complete_sync(self) -> None: class FakeMainContext: def __init__(self, source: FakeSource) -> None: self.source = source + self.pending_count = 1 + self.iterations = 0 def default(self) -> FakeMainContext: return self + def pending(self) -> bool: + return self.pending_count > 0 + + def iteration(self, _may_block: bool) -> None: + self.iterations += 1 + self.pending_count -= 1 + def find_source_by_id(self, source_id: int) -> FakeSource | None: return self.source if source_id == 77 else None @@ -204,50 +162,73 @@ def timeout_add(self, _timeout_ms: int, callback) -> int: return 77 +class FakeVariant: + def __init__(self, signature: str, value: dict[str, float]) -> None: + self.signature = signature + self.value = value + + +class FakeGLib: + Variant = FakeVariant + + +class FakePwgParam: + calls: list[FakeVariant] = [] + + @classmethod + def new_props_controls(cls, variant: FakeVariant): + cls.calls.append(variant) + return ("param", variant) + + +class FakePwg: + Param = FakePwgParam + + def test_parse_metadata_node_name_reads_wireplumber_json_name() -> None: - assert wp_backend.parse_metadata_node_name('{"name":"alsa_output.test"}') == "alsa_output.test" + assert pw_backend.parse_metadata_node_name('{"name":"alsa_output.test"}') == "alsa_output.test" def test_parse_metadata_node_name_accepts_plain_string() -> None: - assert wp_backend.parse_metadata_node_name("mini_eq_sink") == "mini_eq_sink" + assert pw_backend.parse_metadata_node_name("mini_eq_sink") == "mini_eq_sink" def test_parse_metadata_node_name_rejects_invalid_shape() -> None: - assert wp_backend.parse_metadata_node_name("[1, 2, 3]") is None + assert pw_backend.parse_metadata_node_name("[1, 2, 3]") is None def test_parse_bool_property_accepts_wireplumber_truthy_values() -> None: - assert wp_backend.parse_bool_property("true") is True - assert wp_backend.parse_bool_property("1") is True - assert wp_backend.parse_bool_property("false") is False - assert wp_backend.parse_bool_property(None) is False + assert pw_backend.parse_bool_property("true") is True + assert pw_backend.parse_bool_property("1") is True + assert pw_backend.parse_bool_property("false") is False + assert pw_backend.parse_bool_property(None) is False def test_node_sample_rate_uses_audio_rate_and_latency_fallbacks() -> None: - direct_rate = wp_backend.WirePlumberNode( + direct_rate = pw_backend.PipeWireNode( bound_id=39, object_serial="67", - media_class=wp_backend.AUDIO_SINK, + media_class=pw_backend.AUDIO_SINK, node_name="alsa_output.direct", node_description=None, application_name=None, node_dont_move=False, properties={"audio.rate": "48000", "node.max-latency": "1024/44100"}, ) - max_latency_rate = wp_backend.WirePlumberNode( + max_latency_rate = pw_backend.PipeWireNode( bound_id=40, object_serial="68", - media_class=wp_backend.AUDIO_SINK, + media_class=pw_backend.AUDIO_SINK, node_name="alsa_output.max_latency", node_description=None, application_name=None, node_dont_move=False, properties={"node.max-latency": "1024/44100"}, ) - latency_rate = wp_backend.WirePlumberNode( + latency_rate = pw_backend.PipeWireNode( bound_id=41, object_serial="69", - media_class=wp_backend.AUDIO_SINK, + media_class=pw_backend.AUDIO_SINK, node_name="alsa_output.latency", node_description=None, application_name=None, @@ -255,26 +236,26 @@ def test_node_sample_rate_uses_audio_rate_and_latency_fallbacks() -> None: properties={"node.latency": "1024/96000"}, ) - assert wp_backend.node_sample_rate(direct_rate) == 48000.0 - assert wp_backend.node_sample_rate(max_latency_rate) == 44100.0 - assert wp_backend.node_sample_rate(latency_rate) == 96000.0 - assert wp_backend.node_sample_rate(None) == 0.0 + assert pw_backend.node_sample_rate(direct_rate) == 48000.0 + assert pw_backend.node_sample_rate(max_latency_rate) == 44100.0 + assert pw_backend.node_sample_rate(latency_rate) == 96000.0 + assert pw_backend.node_sample_rate(None) == 0.0 def test_node_classification_and_display_name() -> None: - sink = wp_backend.WirePlumberNode( + sink = pw_backend.PipeWireNode( bound_id=39, object_serial="67", - media_class=wp_backend.AUDIO_SINK, + media_class=pw_backend.AUDIO_SINK, node_name="alsa_output.test", node_description="Test Sink", application_name=None, node_dont_move=False, ) - stream = wp_backend.WirePlumberNode( + stream = pw_backend.PipeWireNode( bound_id=126, object_serial="300", - media_class=wp_backend.STREAM_OUTPUT_AUDIO, + media_class=pw_backend.STREAM_OUTPUT_AUDIO, node_name="spotify", node_description=None, application_name="spotify", @@ -289,48 +270,45 @@ def test_node_classification_and_display_name() -> None: assert stream.display_name == "spotify" -def test_new_core_requests_pipewire_manager_access() -> None: - FakeCore.calls = [] +def test_new_core_uses_pipewire_gobject_core_constructor() -> None: + FakeCore.calls = 0 - wp_backend.WirePlumberBackend._new_core(FakeCoreWp) + core = pw_backend.PipeWireBackend._new_core(FakeCorePwg) - assert len(FakeCore.calls) == 1 - _context, _conf, properties = FakeCore.calls[0] - assert properties is not None - assert properties.values == { - "application.name": wp_backend.PIPEWIRE_CLIENT_NAME, - "media.category": wp_backend.PIPEWIRE_MEDIA_CATEGORY, + assert FakeCore.calls == 1 + assert core.pipewire_properties == { + "application.name": "Mini EQ", + "media.category": "Manager", } -def test_sync_core_removes_timeout_source_after_success() -> None: +def test_sync_core_drains_pending_main_context_events() -> None: core = FakeSyncCore() glib = FakeSyncGLib(core) - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() backend._core = core backend._GLib = glib backend._sync_core() - assert glib.source.destroyed is True - assert glib.timeout_callback is not None + assert glib.MainContext.iterations == 1 def test_move_stream_to_target_sets_stream_target_without_metadata_readback() -> None: - backend = wp_backend.WirePlumberBackend() - stream = wp_backend.WirePlumberNode( + backend = pw_backend.PipeWireBackend() + stream = pw_backend.PipeWireNode( bound_id=126, object_serial="300", - media_class=wp_backend.STREAM_OUTPUT_AUDIO, + media_class=pw_backend.STREAM_OUTPUT_AUDIO, node_name="spotify", node_description=None, application_name="spotify", node_dont_move=False, ) - sink = wp_backend.WirePlumberNode( + sink = pw_backend.PipeWireNode( bound_id=39, object_serial="67", - media_class=wp_backend.AUDIO_SINK, + media_class=pw_backend.AUDIO_SINK, node_name="alsa_output.test", node_description="Test Sink", application_name=None, @@ -348,11 +326,11 @@ def test_move_stream_to_target_sets_stream_target_without_metadata_readback() -> def test_move_named_output_stream_to_target_uses_matching_stream() -> None: - backend = wp_backend.WirePlumberBackend() - stream = wp_backend.WirePlumberNode( + backend = pw_backend.PipeWireBackend() + stream = pw_backend.PipeWireNode( bound_id=126, object_serial="300", - media_class=wp_backend.STREAM_OUTPUT_AUDIO, + media_class=pw_backend.STREAM_OUTPUT_AUDIO, node_name="mini_eq_sink_output", node_description=None, application_name=None, @@ -369,22 +347,23 @@ def test_move_named_output_stream_to_target_uses_matching_stream() -> None: def test_move_named_output_stream_to_target_requires_existing_stream() -> None: - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() backend.output_stream_by_name = lambda _name: None - with pytest.raises(wp_backend.WirePlumberError, match="output stream not found: mini_eq_sink_output"): + with pytest.raises(pw_backend.PipeWireBackendError, match="output stream not found: mini_eq_sink_output"): backend.move_named_output_stream_to_target("mini_eq_sink_output", "alsa_output.test") def test_set_stream_target_writes_node_and_object_metadata() -> None: - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() class FakeMetadata: def __init__(self) -> None: self.calls: list[tuple[int, str, str, str]] = [] - def set(self, subject: int, key: str, type_name: str, value: str) -> None: + def set(self, subject: int, key: str, type_name: str, value: str) -> bool: self.calls.append((subject, key, type_name, value)) + return True metadata = FakeMetadata() syncs: list[str] = [] @@ -394,14 +373,72 @@ def set(self, subject: int, key: str, type_name: str, value: str) -> None: backend.set_stream_target(126, 39, "67") assert metadata.calls == [ - (126, wp_backend.TARGET_NODE_KEY, wp_backend.SPA_ID_TYPE, "39"), - (126, wp_backend.TARGET_OBJECT_KEY, wp_backend.SPA_ID_TYPE, "67"), + (126, pw_backend.TARGET_NODE_KEY, pw_backend.SPA_ID_TYPE, "39"), + (126, pw_backend.TARGET_OBJECT_KEY, pw_backend.SPA_ID_TYPE, "67"), + ] + assert syncs == ["sync"] + + +def test_stream_target_reads_node_and_object_metadata() -> None: + backend = pw_backend.PipeWireBackend() + + class FakeMetadata: + def dup_value(self, subject: int, key: str) -> str | None: + values = { + (126, pw_backend.TARGET_NODE_KEY): "39", + (126, pw_backend.TARGET_OBJECT_KEY): "67", + } + return values.get((subject, key)) + + def dup_value_type(self, subject: int, key: str) -> str | None: + values = { + (126, pw_backend.TARGET_NODE_KEY): pw_backend.SPA_ID_TYPE, + (126, pw_backend.TARGET_OBJECT_KEY): pw_backend.SPA_ID_TYPE, + } + return values.get((subject, key)) + + backend._default_metadata = lambda: FakeMetadata() + + target = backend.stream_target(126) + + assert target == pw_backend.PipeWireStreamTarget("39", pw_backend.SPA_ID_TYPE, "67", pw_backend.SPA_ID_TYPE) + + +def test_restore_stream_target_writes_saved_metadata() -> None: + backend = pw_backend.PipeWireBackend() + + class FakeMetadata: + def __init__(self) -> None: + self.calls: list[tuple[int, str, str | None, str | None]] = [] + + def set(self, subject: int, key: str, type_name: str | None, value: str | None) -> bool: + self.calls.append((subject, key, type_name, value)) + return True + + metadata = FakeMetadata() + syncs: list[str] = [] + backend._default_metadata = lambda: metadata + backend._sync_core = lambda: syncs.append("sync") + + backend.restore_stream_target( + 126, + pw_backend.PipeWireStreamTarget( + target_node=None, + target_node_type=None, + target_object=None, + target_object_type=None, + ), + ) + + assert metadata.calls == [ + (126, pw_backend.TARGET_NODE_KEY, None, None), + (126, pw_backend.TARGET_OBJECT_KEY, None, None), ] assert syncs == ["sync"] def test_properties_dict_skips_undecodable_property_values() -> None: - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() proxy = FakePropertyProxy( FakeGlobalProperties( [ @@ -415,52 +452,61 @@ def test_properties_dict_skips_undecodable_property_values() -> None: def test_pw_property_falls_back_when_pipewire_property_is_undecodable() -> None: - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() - class FakePipewireObject: - @staticmethod - def get_property(_proxy, _key: str): + class FakeGlobal(FakePropertyProxy): + def dup_property(self, _key: str): raise UnicodeDecodeError("utf-8", b"\x96", 0, 1, "invalid start byte") - class FakeWirePlumber: - PipewireObject = FakePipewireObject - - backend._Wp = FakeWirePlumber - proxy = FakePropertyProxy(FakeGlobalProperties([], {"node.name": "spotify"})) + proxy = FakeGlobal(FakeGlobalProperties([FakePropertyItem("node.name", "spotify")])) assert backend._pw_property(proxy, "node.name") == "spotify" def test_list_nodes_skips_proxy_with_undecodable_identity() -> None: - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() good_node = object() bad_node = object() - parsed_node = wp_backend.WirePlumberNode( + parsed_node = pw_backend.PipeWireNode( bound_id=1, object_serial="1001", - media_class=wp_backend.STREAM_OUTPUT_AUDIO, + media_class=pw_backend.STREAM_OUTPUT_AUDIO, node_name="spotify", node_description=None, application_name="Spotify", node_dont_move=False, ) - def node_from_proxy(node): + class FakeModel: + def __init__(self, items: list[object]) -> None: + self.items = items + + def get_n_items(self) -> int: + return len(self.items) + + def get_item(self, index: int): + return self.items[index] + + class FakeRegistry: + def dup_globals_by_interface(self, interface_type: str) -> FakeModel: + assert interface_type == pw_backend.PIPEWIRE_NODE_INTERFACE + return FakeModel([bad_node, good_node]) + + def node_from_global(node): if node is bad_node: raise UnicodeDecodeError("utf-8", b"\xea", 3, 4, "invalid continuation byte") return parsed_node backend._ensure_connected = lambda: None - backend._node_manager = object() - backend._iterate_manager = lambda _manager: [bad_node, good_node] - backend._node_from_proxy = node_from_proxy + backend._registry = FakeRegistry() + backend._node_from_global = node_from_global assert backend.list_nodes() == [parsed_node] def test_defaults_returns_cached_value_without_metadata_read(monkeypatch) -> None: - backend = wp_backend.WirePlumberBackend() - backend._cached_defaults = wp_backend.WirePlumberDefaults("cached.default", "cached.configured") + backend = pw_backend.PipeWireBackend() + backend._cached_defaults = pw_backend.PipeWireDefaults("cached.default", "cached.configured") reads: list[bool] = [] monkeypatch.setattr(backend, "_read_defaults", lambda: reads.append(True)) @@ -470,8 +516,8 @@ def test_defaults_returns_cached_value_without_metadata_read(monkeypatch) -> Non def test_refresh_defaults_falls_back_to_cache_on_undecodable_metadata(monkeypatch) -> None: - backend = wp_backend.WirePlumberBackend() - backend._cached_defaults = wp_backend.WirePlumberDefaults("cached.default", None) + backend = pw_backend.PipeWireBackend() + backend._cached_defaults = pw_backend.PipeWireDefaults("cached.default", None) syncs: list[bool] = [] def raise_decode_error(): @@ -485,91 +531,111 @@ def raise_decode_error(): def test_remember_default_metadata_change_updates_cache() -> None: - backend = wp_backend.WirePlumberBackend() + backend = pw_backend.PipeWireBackend() assert backend.remember_default_metadata_change( - wp_backend.DEFAULT_AUDIO_SINK_KEY, + pw_backend.DEFAULT_AUDIO_SINK_KEY, '{"name":"alsa_output.new"}', ) assert backend.defaults().default_audio_sink == "alsa_output.new" -def test_build_spa_params_pod_uses_filter_chain_props_shape() -> None: - FakeSpaPodBuilder.builders = [] +def test_build_props_controls_param_uses_variant_control_map() -> None: + FakePwgParam.calls = [] - pod = wp_backend.build_spa_params_pod(FakeWp, {"eq:enabled": 0.0, "eq:g_out": 1.0}) + param = pw_backend.build_props_controls_param(FakePwg, FakeGLib, {"eq:enabled": 0.0, "eq:g_out": 1.0}) - struct_builder, object_builder = FakeSpaPodBuilder.builders - assert pod is object_builder - assert struct_builder.kind == "struct" - assert struct_builder.calls == [ - ("string", "eq:enabled"), - ("float", 0.0), - ("string", "eq:g_out"), - ("float", 1.0), - ] - assert object_builder.kind == "object" - assert object_builder.args == ("Spa:Pod:Object:Param:Props", "Props") - assert object_builder.calls == [("property", "params"), ("pod", struct_builder)] + variant = FakePwgParam.calls[0] + assert param == ("param", variant) + assert variant.signature == "a{sd}" + assert variant.value == {"eq:enabled": 0.0, "eq:g_out": 1.0} -def test_set_node_params_uses_wireplumber_set_param(monkeypatch: pytest.MonkeyPatch) -> None: - FakeSpaPodBuilder.builders = [] - node = FakeNodeProxy(42) - backend = wp_backend.WirePlumberBackend() - backend._Wp = FakeWp - backend._node_manager = object() +def test_set_node_params_uses_pwg_node_set_param(monkeypatch: pytest.MonkeyPatch) -> None: + FakePwgParam.calls = [] + + class FakeLiveNode: + def __init__(self) -> None: + self.set_calls: list[object] = [] + + def set_param(self, param) -> bool: + self.set_calls.append(param) + return True + + node = FakeLiveNode() + backend = pw_backend.PipeWireBackend() + backend._Pwg = FakePwg + backend._GLib = FakeGLib monkeypatch.setattr(backend, "_ensure_connected", lambda: None) - monkeypatch.setattr(backend, "_iterate_manager", lambda _manager: [node]) + monkeypatch.setattr(backend, "_node_proxy_by_bound_id", lambda _bound_id: node) backend.set_node_params(42, {"eq:enabled": 1.0}) - assert node.set_calls == [("Props", 0, FakeSpaPodBuilder.builders[-1])] + assert node.set_calls == [("param", FakePwgParam.calls[-1])] def test_set_node_params_raises_when_node_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: - backend = wp_backend.WirePlumberBackend() - backend._Wp = FakeWp - backend._node_manager = object() + backend = pw_backend.PipeWireBackend() + backend._Pwg = FakePwg + backend._GLib = FakeGLib monkeypatch.setattr(backend, "_ensure_connected", lambda: None) - monkeypatch.setattr(backend, "_iterate_manager", lambda _manager: []) + monkeypatch.setattr(backend, "_node_proxy_by_bound_id", lambda _bound_id: None) - with pytest.raises(wp_backend.WirePlumberError, match="node not found"): + with pytest.raises(pw_backend.PipeWireBackendError, match="node not found"): backend.set_node_params(42, {"eq:enabled": 1.0}) -def test_load_filter_chain_module_uses_wireplumber_impl_module(monkeypatch: pytest.MonkeyPatch) -> None: - FakeImplModule.load_calls = [] - FakeImplModule.result = object() - backend = wp_backend.WirePlumberBackend() - backend._Wp = FakeWp - backend._core = object() +def test_load_filter_chain_module_uses_pwg_core_load_module(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeLoadCore: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + self.result = object() + + def load_module(self, name: str, arguments: str): + self.calls.append((name, arguments)) + return self.result + + core = FakeLoadCore() + backend = pw_backend.PipeWireBackend() + backend._core = core monkeypatch.setattr(backend, "_ensure_connected", lambda: None) module = backend.load_filter_chain_module("{ node.name = test }") - assert module is FakeImplModule.result - assert FakeImplModule.load_calls == [ - ( - backend._core, - wp_backend.FILTER_CHAIN_MODULE_NAME, - "{ node.name = test }", - None, - ) - ] + assert module is core.result + assert core.calls == [(pw_backend.FILTER_CHAIN_MODULE_NAME, "{ node.name = test }")] + assert backend._loaded_modules == [module] + + +def test_unload_filter_chain_module_unloads_and_forgets_loaded_module() -> None: + calls: list[str] = [] + + class FakeModule: + def unload(self) -> None: + calls.append("unload") + + module = FakeModule() + backend = pw_backend.PipeWireBackend() + backend._loaded_modules = [module] + + backend.unload_filter_chain_module(module) + + assert calls == ["unload"] + assert backend._loaded_modules == [] def test_load_filter_chain_module_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: - FakeImplModule.load_calls = [] - FakeImplModule.result = None - backend = wp_backend.WirePlumberBackend() - backend._Wp = FakeWp - backend._core = object() + class FakeLoadCore: + def load_module(self, _name: str, _arguments: str): + return None + + backend = pw_backend.PipeWireBackend() + backend._core = FakeLoadCore() monkeypatch.setattr(backend, "_ensure_connected", lambda: None) - with pytest.raises(wp_backend.WirePlumberError, match="failed to load PipeWire module"): + with pytest.raises(pw_backend.PipeWireBackendError, match="failed to load PipeWire module"): backend.load_filter_chain_module("{}") diff --git a/tests/test_mini_eq_pipewire_stream_router.py b/tests/test_mini_eq_pipewire_stream_router.py new file mode 100644 index 0000000..cd6436d --- /dev/null +++ b/tests/test_mini_eq_pipewire_stream_router.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +import pytest + +from tests._mini_eq_imports import pipewire_backend as pw_backend +from tests._mini_eq_imports import pipewire_stream_router as pw_router + + +def make_node( + bound_id: int, + media_class: str, + node_name: str, + application_name: str | None = None, + properties: dict[str, str] | None = None, +) -> pw_backend.PipeWireNode: + return pw_backend.PipeWireNode( + bound_id=bound_id, + object_serial=str(bound_id + 1000), + media_class=media_class, + node_name=node_name, + node_description=None, + application_name=application_name, + node_dont_move=False, + properties=properties or {}, + ) + + +def no_stream_target() -> pw_backend.PipeWireStreamTarget: + return pw_backend.PipeWireStreamTarget(None, None, None, None) + + +class FakePipeWireBackend: + def __init__( + self, + streams: list[pw_backend.PipeWireNode], + sinks: list[pw_backend.PipeWireNode] | None = None, + target_nodes: dict[int, str] | None = None, + stream_targets: dict[int, pw_backend.PipeWireStreamTarget] | None = None, + ) -> None: + self.streams = streams + self.sinks = sinks or [] + self.target_nodes = target_nodes or {} + self.stream_targets = stream_targets or {} + self.moves: list[tuple[int, str]] = [] + self.restores: list[tuple[int, pw_backend.PipeWireStreamTarget]] = [] + self.connected = False + self.closed = False + self.disconnected_handlers: list[int] = [] + self.missing_stream_ids: set[int] = set() + self.move_failures: dict[int, Exception] = {} + + def connect(self) -> None: + self.connected = True + + def close(self) -> None: + self.closed = True + + def list_output_streams(self) -> list[pw_backend.PipeWireNode]: + return self.streams + + def audio_sink_by_name(self, node_name: str) -> pw_backend.PipeWireNode | None: + for sink in self.sinks: + if sink.node_name == node_name: + return sink + return None + + def stream_target(self, stream_bound_id: int) -> pw_backend.PipeWireStreamTarget: + if stream_bound_id in self.missing_stream_ids: + raise pw_backend.PipeWireBackendError(f"output stream not found: {stream_bound_id}") + + return self.stream_targets.get(stream_bound_id, no_stream_target()) + + def move_stream_to_target(self, stream_bound_id: int, target_node_name: str) -> None: + if stream_bound_id in self.missing_stream_ids: + raise pw_backend.PipeWireBackendError(f"output stream not found: {stream_bound_id}") + if stream_bound_id in self.move_failures: + raise self.move_failures[stream_bound_id] + + self.moves.append((stream_bound_id, target_node_name)) + self.target_nodes[stream_bound_id] = target_node_name + + def restore_stream_target(self, stream_bound_id: int, target: pw_backend.PipeWireStreamTarget) -> None: + if stream_bound_id in self.missing_stream_ids: + raise pw_backend.PipeWireBackendError(f"output stream not found: {stream_bound_id}") + + self.restores.append((stream_bound_id, target)) + self.stream_targets[stream_bound_id] = target + if target.target_node is None and target.target_object is None: + self.target_nodes.pop(stream_bound_id, None) + else: + self.target_nodes[stream_bound_id] = target.target_object or target.target_node or "" + + def node_from_proxy(self, node): + return node + + def connect_object_added(self, _callback) -> int: + return 42 + + def disconnect_node_manager_handler(self, handler_id: int) -> None: + self.disconnected_handlers.append(handler_id) + + +def test_pipewire_router_moves_only_external_output_streams() -> None: + backend = FakePipeWireBackend( + [ + make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), + make_node(2, pw_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_output"), + make_node(3, pw_backend.STREAM_OUTPUT_AUDIO, "control", pw_router.OUTPUT_CLIENT_NAME), + make_node(4, pw_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_1_output"), + ] + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + + routed_now = router.route_output_streams() + + assert routed_now == 1 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_ids == {1} + assert router.routed_stream_targets == {1: no_stream_target()} + + +def test_pipewire_router_skips_notification_and_system_event_streams() -> None: + backend = FakePipeWireBackend( + [ + make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), + make_node(2, pw_backend.STREAM_OUTPUT_AUDIO, "bell", "libcanberra", {"media.role": "event"}), + make_node(3, pw_backend.STREAM_OUTPUT_AUDIO, "GNOME Shell", "GNOME Shell"), + ] + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + + routed_now = router.route_output_streams() + + assert routed_now == 1 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_ids == {1} + + +def test_pipewire_router_skips_stream_targeting_different_output_device() -> None: + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify", {"target.object": "hdmi"})], + sinks=[ + make_node(10, pw_backend.AUDIO_SINK, "speakers"), + make_node(11, pw_backend.AUDIO_SINK, "mini_eq_sink"), + ], + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.set_output_sink_name("speakers") + + routed_now = router.route_output_streams() + + assert routed_now == 0 + assert backend.moves == [] + assert router.routed_stream_ids == set() + + +def test_pipewire_router_routes_stream_targeting_selected_output_device() -> None: + output_sink = make_node(10, pw_backend.AUDIO_SINK, "speakers") + backend = FakePipeWireBackend( + [ + make_node( + 1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify", {"target.object": output_sink.object_serial} + ) + ], + sinks=[ + output_sink, + make_node(11, pw_backend.AUDIO_SINK, "mini_eq_sink"), + ], + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.set_output_sink_name("speakers") + + routed_now = router.route_output_streams() + + assert routed_now == 1 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_ids == {1} + + +def test_pipewire_router_restores_tracked_external_streams() -> None: + original_target = pw_backend.PipeWireStreamTarget("23", "Spa:Id", "1001", "Spa:Id") + backend = FakePipeWireBackend( + [ + make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), + make_node(2, pw_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_output"), + ] + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.set_output_sink_name("speakers") + router.routed_stream_ids = {1, 2, 99} + router.routed_stream_targets = {1: original_target} + + restored = router.restore_output_streams() + + assert restored == 1 + assert backend.restores == [(1, original_target)] + assert router.routed_stream_ids == set() + assert router.routed_stream_targets == {} + + +def test_pipewire_router_rewrites_tracked_route_target_without_metadata_readback() -> None: + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + {1: "mini_eq_sink"}, + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.routed_stream_ids = {1} + + routed_now = router.route_output_streams() + + assert routed_now == 0 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_ids == {1} + assert router.routed_stream_targets == {1: no_stream_target()} + + +def test_pipewire_router_routes_without_target_metadata_preflight() -> None: + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + {1: "mini_eq_sink"}, + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + + routed_now = router.route_output_streams() + + assert routed_now == 1 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_ids == {1} + assert router.routed_stream_targets == {1: no_stream_target()} + + +def test_pipewire_router_snapshots_existing_stream_target_before_routing() -> None: + original_target = pw_backend.PipeWireStreamTarget("23", "Spa:Id", "1001", "Spa:Id") + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + stream_targets={1: original_target}, + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + + routed_now = router.route_output_streams() + + assert routed_now == 1 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_targets == {1: original_target} + + +def test_pipewire_router_treats_existing_virtual_sink_target_as_own_override() -> None: + virtual_sink = make_node(11, pw_backend.AUDIO_SINK, "mini_eq_sink") + stale_target = pw_backend.PipeWireStreamTarget( + str(virtual_sink.bound_id), + "Spa:Id", + virtual_sink.object_serial, + "Spa:Id", + ) + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + sinks=[virtual_sink], + stream_targets={1: stale_target}, + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + + routed_now = router.route_output_streams() + + assert routed_now == 1 + assert backend.moves == [(1, "mini_eq_sink")] + assert router.routed_stream_targets == {1: no_stream_target()} + + +def test_pipewire_router_drops_stream_that_disappears_during_route() -> None: + backend = FakePipeWireBackend( + [make_node(92, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + ) + backend.missing_stream_ids = {92} + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.routed_stream_ids = {92} + + routed_now = router.route_output_streams() + + assert routed_now == 0 + assert backend.moves == [] + assert router.routed_stream_ids == set() + assert router.routed_stream_targets == {} + + +def test_pipewire_router_enable_raises_and_stops_monitoring_on_initial_route_error() -> None: + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + ) + statuses: list[str] = [] + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", statuses.append, backend) + + def fail_move(_stream_bound_id: int, _target_node_name: str) -> None: + raise RuntimeError("metadata permission denied") + + backend.move_stream_to_target = fail_move + + with pytest.raises(RuntimeError, match="metadata permission denied"): + router.enable() + + assert router.enabled is False + assert router.accept_stream_events is False + assert backend.disconnected_handlers == [42] + assert statuses == ["routing warning: metadata permission denied"] + + +def test_pipewire_router_enable_restores_partial_initial_route_failure() -> None: + backend = FakePipeWireBackend( + [ + make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), + make_node(2, pw_backend.STREAM_OUTPUT_AUDIO, "browser", "Browser"), + ] + ) + backend.move_failures = {2: RuntimeError("metadata permission denied")} + statuses: list[str] = [] + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", statuses.append, backend) + router.set_output_sink_name("speakers") + + with pytest.raises(RuntimeError, match="metadata permission denied"): + router.enable() + + assert router.enabled is False + assert router.routed_stream_ids == set() + assert backend.moves == [(1, "mini_eq_sink")] + assert backend.restores == [(1, no_stream_target())] + assert 1 not in backend.target_nodes + assert statuses == ["routing warning: metadata permission denied"] + + +def test_pipewire_router_falls_back_to_output_sink_when_original_target_is_unknown() -> None: + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + {1: "speakers"}, + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.set_output_sink_name("speakers") + router.routed_stream_ids = {1} + + restored = router.restore_output_streams() + + assert restored == 1 + assert backend.moves == [(1, "speakers")] + assert router.routed_stream_ids == set() + + +def test_pipewire_router_clears_target_when_stream_had_no_original_target() -> None: + backend = FakePipeWireBackend( + [make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + {1: "mini_eq_sink"}, + ) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.set_output_sink_name("speakers") + router.routed_stream_ids = {1} + router.routed_stream_targets = {1: no_stream_target()} + + restored = router.restore_output_streams() + + assert restored == 1 + assert backend.moves == [] + assert backend.restores == [(1, no_stream_target())] + assert 1 not in backend.target_nodes + assert router.routed_stream_ids == set() + assert router.routed_stream_targets == {} + + +def test_pipewire_router_drops_stream_that_disappears_during_restore() -> None: + backend = FakePipeWireBackend( + [make_node(92, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], + ) + backend.missing_stream_ids = {92} + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + router.set_output_sink_name("speakers") + router.routed_stream_ids = {92} + + restored = router.restore_output_streams() + + assert restored == 0 + assert backend.moves == [] + assert router.routed_stream_ids == set() + assert router.routed_stream_targets == {} + + +def test_pipewire_router_schedules_one_refresh_for_new_output_stream(monkeypatch: pytest.MonkeyPatch) -> None: + backend = FakePipeWireBackend([]) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + scheduled_callbacks: list[object] = [] + + monkeypatch.setattr( + pw_router.GLib, + "idle_add", + lambda callback: scheduled_callbacks.append(callback) or 321, + ) + + router.accept_stream_events = True + stream = make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify") + router.handle_object_added(None, stream) + router.handle_object_added(None, stream) + + assert router.event_source_id == 321 + assert len(scheduled_callbacks) == 1 + + +def test_pipewire_router_ignores_new_non_output_stream(monkeypatch: pytest.MonkeyPatch) -> None: + backend = FakePipeWireBackend([]) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + scheduled_callbacks: list[object] = [] + + monkeypatch.setattr( + pw_router.GLib, + "idle_add", + lambda callback: scheduled_callbacks.append(callback) or 321, + ) + + router.accept_stream_events = True + sink = make_node(1, pw_backend.AUDIO_SINK, "speakers") + router.handle_object_added(None, sink) + + assert router.event_source_id == 0 + assert scheduled_callbacks == [] + + +def test_pipewire_router_close_does_not_close_shared_backend() -> None: + backend = FakePipeWireBackend([]) + router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) + + router.enable() + + assert backend.connected is True + assert router.object_added_handler_id == 42 + + router.close() + + assert backend.disconnected_handlers == [42] + assert backend.closed is False diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py index 1f1395a..7b55272 100644 --- a/tests/test_mini_eq_routing.py +++ b/tests/test_mini_eq_routing.py @@ -3,16 +3,16 @@ import pytest from tests._mini_eq_imports import core, routing -from tests._mini_eq_imports import wireplumber_backend as wp_backend +from tests._mini_eq_imports import pipewire_backend as pw_backend def make_node( bound_id: int, name: str | None, - media_class: str = wp_backend.AUDIO_SINK, + media_class: str = pw_backend.AUDIO_SINK, properties: dict[str, str] | None = None, -) -> wp_backend.WirePlumberNode: - return wp_backend.WirePlumberNode( +) -> pw_backend.PipeWireNode: + return pw_backend.PipeWireNode( bound_id=bound_id, object_serial=str(bound_id + 1000), media_class=media_class, @@ -25,13 +25,13 @@ def make_node( class FakeOutputBackend: - def __init__(self, sinks: list[wp_backend.WirePlumberNode]) -> None: + def __init__(self, sinks: list[pw_backend.PipeWireNode]) -> None: self.sinks = sinks - def list_audio_sinks(self) -> list[wp_backend.WirePlumberNode]: + def list_audio_sinks(self) -> list[pw_backend.PipeWireNode]: return self.sinks - def audio_sink_by_name(self, sink_name: str) -> wp_backend.WirePlumberNode | None: + def audio_sink_by_name(self, sink_name: str) -> pw_backend.PipeWireNode | None: for sink in self.sinks: if sink.node_name == sink_name: return sink @@ -39,6 +39,26 @@ def audio_sink_by_name(self, sink_name: str) -> wp_backend.WirePlumberNode | Non return None +class FakeDefaultOutputBackend(FakeOutputBackend): + def __init__( + self, + sinks: list[pw_backend.PipeWireNode], + cached_defaults: pw_backend.PipeWireDefaults, + refreshed_defaults: pw_backend.PipeWireDefaults, + ) -> None: + super().__init__(sinks) + self.cached_defaults = cached_defaults + self.refreshed_defaults = refreshed_defaults + self.refresh_count = 0 + + def defaults(self) -> pw_backend.PipeWireDefaults: + return self.cached_defaults + + def refresh_defaults(self) -> pw_backend.PipeWireDefaults: + self.refresh_count += 1 + return self.refreshed_defaults + + def test_list_output_sink_names_uses_wireplumber_sinks_and_filters_internal_nodes() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.output_backend = FakeOutputBackend( @@ -61,6 +81,70 @@ def test_get_sink_uses_wireplumber_node_name() -> None: assert routing.SystemWideEqController.get_sink(controller, "missing") is None +def test_get_default_output_sink_name_uses_cached_metadata_by_default() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + backend = FakeDefaultOutputBackend( + [make_node(1, "cached-speakers"), make_node(2, "fresh-speakers")], + cached_defaults=pw_backend.PipeWireDefaults("cached-current", "cached-speakers"), + refreshed_defaults=pw_backend.PipeWireDefaults("fresh-speakers", "fresh-configured"), + ) + controller.output_backend = backend + + assert routing.SystemWideEqController.get_default_output_sink_name(controller) == "cached-speakers" + assert backend.refresh_count == 0 + + +def test_get_default_output_sink_name_skips_virtual_default_when_configured_sink_is_available() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + controller.output_backend = FakeDefaultOutputBackend( + [make_node(1, "speakers")], + cached_defaults=pw_backend.PipeWireDefaults("mini_eq_sink", "speakers"), + refreshed_defaults=pw_backend.PipeWireDefaults("fresh-speakers", None), + ) + + assert routing.SystemWideEqController.get_default_output_sink_name(controller) == "speakers" + + +def test_get_default_output_sink_name_prefers_configured_sink_over_current_sink() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + controller.output_backend = FakeDefaultOutputBackend( + [make_node(1, "current-speakers"), make_node(2, "configured-speakers")], + cached_defaults=pw_backend.PipeWireDefaults("current-speakers", "configured-speakers"), + refreshed_defaults=pw_backend.PipeWireDefaults("fresh-speakers", None), + ) + + assert routing.SystemWideEqController.get_default_output_sink_name(controller) == "configured-speakers" + + +def test_resolve_default_output_sink_name_falls_back_to_first_real_sink_when_metadata_is_virtual() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + controller.output_backend = FakeDefaultOutputBackend( + [make_node(1, "speakers"), make_node(2, "mini_eq_sink")], + cached_defaults=pw_backend.PipeWireDefaults("mini_eq_sink", None), + refreshed_defaults=pw_backend.PipeWireDefaults("mini_eq_sink", None), + ) + + assert routing.SystemWideEqController.resolve_default_output_sink_name(controller) == "speakers" + + +def test_refresh_followed_output_sink_refreshes_metadata_and_skips_virtual_defaults() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + backend = FakeDefaultOutputBackend( + [make_node(1, "speakers")], + cached_defaults=pw_backend.PipeWireDefaults("old-speakers", None), + refreshed_defaults=pw_backend.PipeWireDefaults("mini_eq_sink", "speakers"), + ) + controller.output_backend = backend + controller.follow_default_output = True + calls: list[object] = [] + + controller.switch_output_sink = lambda sink_name, explicit: calls.append((sink_name, explicit)) + + assert routing.SystemWideEqController.refresh_followed_output_sink(controller) is True + assert backend.refresh_count == 1 + assert calls == [("speakers", False)] + + def test_output_metadata_change_schedules_one_refresh(monkeypatch: pytest.MonkeyPatch) -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.accept_output_events = True @@ -78,7 +162,7 @@ def test_output_metadata_change_schedules_one_refresh(monkeypatch: pytest.Monkey controller, None, 0, - wp_backend.DEFAULT_AUDIO_SINK_KEY, + pw_backend.DEFAULT_AUDIO_SINK_KEY, None, None, ) @@ -86,7 +170,7 @@ def test_output_metadata_change_schedules_one_refresh(monkeypatch: pytest.Monkey controller, None, 0, - wp_backend.DEFAULT_CONFIGURED_AUDIO_SINK_KEY, + pw_backend.DEFAULT_CONFIGURED_AUDIO_SINK_KEY, None, None, ) @@ -111,7 +195,7 @@ def test_output_object_added_schedules_refresh_only_for_audio_sinks(monkeypatch: routing.SystemWideEqController.handle_output_object_added( controller, None, - make_node(1, "spotify", wp_backend.STREAM_OUTPUT_AUDIO), + make_node(1, "spotify", pw_backend.STREAM_OUTPUT_AUDIO), ) routing.SystemWideEqController.handle_output_object_added(controller, None, make_node(2, "speakers")) @@ -216,7 +300,7 @@ def start_engine() -> None: ] -def test_enabling_analyzer_while_engine_runs_opens_jack_before_restarting_engine() -> None: +def test_enabling_analyzer_while_engine_runs_opens_stream_before_restarting_engine() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.running = True controller.routed = True @@ -546,8 +630,14 @@ def test_shutdown_skips_route_restore_when_routing_is_inactive() -> None: calls: list[str] = [] class FakeBackend: + def unload_filter_chain_module(self, _module) -> None: + calls.append("unload-engine") + + def sync(self) -> None: + calls.append("sync-engine") + def close(self) -> None: - raise AssertionError("controller shutdown should not explicitly disconnect WirePlumber") + calls.append("close-backend") controller.routed = False controller.stream_router = None @@ -561,7 +651,7 @@ def close(self) -> None: routing.SystemWideEqController.shutdown(controller) - assert calls == ["stop-monitor"] + assert calls == ["stop-monitor", "unload-engine", "sync-engine", "close-backend"] assert controller.engine_module is None assert controller.filter_node_id is None assert controller.running is False @@ -572,8 +662,14 @@ def test_shutdown_restores_routed_streams_without_refreshing_followed_output() - calls: list[object] = [] class FakeBackend: + def unload_filter_chain_module(self, _module) -> None: + calls.append("unload-engine") + + def sync(self) -> None: + calls.append("sync-engine") + def close(self) -> None: - raise AssertionError("controller shutdown should not explicitly disconnect WirePlumber") + calls.append("close-backend") class FakeStreamRouter: def set_output_sink_name(self, sink_name: str) -> None: @@ -603,6 +699,9 @@ def close(self) -> None: ("target", "speakers"), ("disable", False), "close-router", + "unload-engine", + "sync-engine", + "close-backend", ] assert controller.routed is False assert controller.engine_module is None @@ -759,6 +858,33 @@ def disable(self, announce: bool = True) -> None: assert controller.routed is False +def test_stop_engine_unloads_filter_chain_module_before_clearing_state() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + module = object() + calls: list[str] = [] + + class FakeBackend: + def unload_filter_chain_module(self, loaded_module) -> None: + assert loaded_module is module + calls.append("unload") + + def sync(self) -> None: + calls.append("sync") + + controller.engine_module = module + controller.filter_node_id = 42 + controller.running = True + controller.output_backend = FakeBackend() + controller.emit_status = lambda message: calls.append(f"status:{message}") + + routing.SystemWideEqController.stop_engine(controller, announce=False) + + assert calls == ["unload", "sync"] + assert controller.engine_module is None + assert controller.filter_node_id is None + assert controller.running is False + + def test_restart_engine_pauses_stream_router_monitoring_during_restart() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.running = True diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py index 3732966..3db8cc1 100644 --- a/tests/test_mini_eq_window.py +++ b/tests/test_mini_eq_window.py @@ -25,6 +25,40 @@ def set_state(self, state: bool) -> None: self.state = state +class FakeStateLabel: + def __init__(self) -> None: + self.text = "" + self.tooltip = None + self.css_classes: set[str] = set() + + def set_text(self, text: str) -> None: + self.text = text + + def set_tooltip_text(self, text: str | None) -> None: + self.tooltip = text + + def add_css_class(self, css_class: str) -> None: + self.css_classes.add(css_class) + + def remove_css_class(self, css_class: str) -> None: + self.css_classes.discard(css_class) + + +def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None: + fake_window.sync_control_switches_from_controller = MethodType( + window.MiniEqWindow.sync_control_switches_from_controller, + fake_window, + ) + fake_window.refresh_after_route_state_changed = MethodType( + window.MiniEqWindow.refresh_after_route_state_changed, + fake_window, + ) + fake_window.refresh_after_eq_state_changed = MethodType( + window.MiniEqWindow.refresh_after_eq_state_changed, + fake_window, + ) + + def test_on_close_request_starts_custom_shutdown_sequence() -> None: calls: list[str] = [] fake_window = SimpleNamespace( @@ -69,6 +103,7 @@ def test_begin_close_request_shutdown_restores_routing_before_delayed_quit(monke ("route", enabled, announce, refresh_output) ) ), + is_system_routed=lambda: True, update_info_label=lambda: calls.append("info"), update_status_summary=lambda: calls.append("summary"), set_visible=lambda visible: calls.append(("visible", visible)), @@ -97,6 +132,51 @@ def test_begin_close_request_shutdown_restores_routing_before_delayed_quit(monke assert application.quit_count == 1 +def test_begin_close_request_shutdown_uses_controller_route_state(monkeypatch) -> None: + scheduled: list[tuple[int, object]] = [] + application = SimpleNamespace(quit_count=0) + application.quit = lambda: setattr(application, "quit_count", application.quit_count + 1) + calls: list[object] = [] + + monkeypatch.setattr( + window.GLib, + "timeout_add", + lambda delay_ms, callback: scheduled.append((delay_ms, callback)) or 321, + ) + + fake_window = SimpleNamespace( + ui_shutting_down=False, + close_finish_source_id=0, + updating_ui=False, + route_switch=FakeSwitch(False), + controller=SimpleNamespace( + routed=True, + route_system_audio=lambda enabled, announce=True, refresh_output=True: calls.append( + ("route", enabled, announce, refresh_output) + ), + ), + is_system_routed=lambda: True, + update_info_label=lambda: calls.append("info"), + update_status_summary=lambda: calls.append("summary"), + set_visible=lambda visible: calls.append(("visible", visible)), + prepare_for_shutdown=lambda: calls.append("prepare"), + get_application=lambda: application, + ) + fake_window.finish_close_request = MethodType(window.MiniEqWindow.finish_close_request, fake_window) + + window.MiniEqWindow.begin_close_request_shutdown(fake_window) + + assert fake_window.route_switch.get_active() is False + assert calls == [ + ("route", False, False, False), + "info", + "summary", + ("visible", False), + "prepare", + ] + assert scheduled[0][0] == window.ROUTING_CLOSE_SETTLE_MS + + def test_begin_close_request_shutdown_hides_when_background_mode_is_enabled() -> None: calls: list[object] = [] application = SimpleNamespace(background_mode=True) @@ -128,9 +208,9 @@ def test_begin_close_request_shutdown_hides_when_background_mode_is_enabled() -> ] -def test_post_present_setup_routes_before_starting_monitor_for_auto_route() -> None: +def test_post_present_setup_schedules_auto_route_after_startup_work() -> None: calls: list[object] = [] - controller = SimpleNamespace(routed=False) + controller = SimpleNamespace(eq_enabled=True, routed=False) def route_system_audio(enabled: bool) -> None: calls.append(("route", enabled)) @@ -140,6 +220,7 @@ def route_system_audio(enabled: bool) -> None: fake_window = SimpleNamespace( post_present_source_id=99, + startup_auto_route_source_id=0, ui_shutting_down=False, post_present_ready=False, auto_route_on_startup=True, @@ -149,38 +230,102 @@ def route_system_audio(enabled: bool) -> None: controller=controller, start_preset_monitoring=lambda: calls.append("preset-monitor"), apply_output_preset_for_current_output=lambda: calls.append("output-preset"), - update_eq_power_indicator=lambda: calls.append("power"), - update_info_label=lambda: calls.append("info"), - update_status_summary=lambda: calls.append("summary"), + update_eq_power_indicator=lambda: calls.append(("power", fake_window.route_switch.get_active())), + update_info_label=lambda: calls.append(("info", fake_window.route_switch.get_active())), + update_status_summary=lambda: calls.append(("summary", fake_window.route_switch.get_active())), update_focus_summary=lambda: calls.append("focus"), start_analyzer_preview=lambda: calls.append("monitor"), notify_control_state_changed=lambda: calls.append("notify"), set_status=lambda message: calls.append(("status", message)), + schedule_startup_auto_route=lambda: calls.append("schedule-route"), present=lambda: calls.append("present"), ) + fake_window.bypass_switch = FakeSwitch(True) + bind_control_refresh_methods(fake_window) keep_source = window.MiniEqWindow.on_post_present_setup_idle(fake_window) assert keep_source is False assert fake_window.post_present_source_id == 0 assert fake_window.post_present_ready is True - assert fake_window.route_switch.get_active() is True - assert fake_window.route_switch.get_state() is True + assert fake_window.route_switch.get_active() is False + assert fake_window.route_switch.get_state() is False assert calls == [ "preset-monitor", "output-preset", + "monitor", + "notify", + "schedule-route", + ] + + +def test_startup_auto_route_idle_routes_after_startup_work() -> None: + calls: list[object] = [] + controller = SimpleNamespace(eq_enabled=True, routed=False) + + def route_system_audio(enabled: bool) -> None: + calls.append(("route", enabled)) + controller.routed = enabled + + controller.route_system_audio = route_system_audio + + fake_window = SimpleNamespace( + startup_auto_route_source_id=321, + ui_shutting_down=False, + auto_route_on_startup=True, + updating_ui=False, + route_switch=FakeSwitch(False), + bypass_switch=FakeSwitch(True), + controller=controller, + update_eq_power_indicator=lambda: calls.append(("power", fake_window.route_switch.get_active())), + update_info_label=lambda: calls.append(("info", fake_window.route_switch.get_active())), + update_status_summary=lambda: calls.append(("summary", fake_window.route_switch.get_active())), + update_focus_summary=lambda: calls.append("focus"), + set_status=lambda message: calls.append(("status", message)), + notify_control_state_changed=lambda: calls.append("notify"), + ) + bind_control_refresh_methods(fake_window) + + keep_source = window.MiniEqWindow.on_startup_auto_route_idle(fake_window) + + assert keep_source is False + assert fake_window.startup_auto_route_source_id == 0 + assert fake_window.route_switch.get_active() is True + assert fake_window.route_switch.get_state() is True + assert calls == [ ("route", True), - "power", - "info", - "summary", + ("power", True), + ("info", True), + ("summary", True), "focus", - "monitor", "notify", ] +def test_status_summary_uses_controller_route_state_over_stale_switch() -> None: + headroom_states: list[dict[str, object]] = [] + state_label = FakeStateLabel() + fake_window = SimpleNamespace( + route_switch=FakeSwitch(False), + controller=SimpleNamespace(eq_enabled=True), + system_state_label=state_label, + output_sink_info=lambda: object(), + is_system_routed=lambda: True, + profile_summary=lambda _sink: ("USB output", "48 kHz", False, []), + estimate_curve_peak_db=lambda: -3.0, + set_headroom_state=lambda **kwargs: headroom_states.append(kwargs), + ) + + window.MiniEqWindow.update_status_summary(fake_window) + + assert state_label.text == "Applied" + assert "system-state-live" in state_label.css_classes + assert headroom_states[-1]["kind"] == "safe" + + def test_on_route_changed_resets_switch_when_routing_fails() -> None: calls: list[object] = [] + route_switch = FakeSwitch(True) def fail_route(_enabled: bool) -> None: raise RuntimeError("metadata permission denied") @@ -192,6 +337,8 @@ def fail_route(_enabled: bool) -> None: routed=False, route_system_audio=fail_route, ), + route_switch=route_switch, + bypass_switch=FakeSwitch(True), update_eq_power_indicator=lambda: calls.append("power"), update_info_label=lambda: calls.append("info"), update_status_summary=lambda: calls.append("summary"), @@ -199,7 +346,7 @@ def fail_route(_enabled: bool) -> None: set_status=lambda message: calls.append(("status", message)), notify_control_state_changed=lambda: calls.append("notify"), ) - route_switch = FakeSwitch(True) + bind_control_refresh_methods(fake_window) handled = window.MiniEqWindow.on_route_changed(fake_window, route_switch, None) @@ -213,6 +360,7 @@ def fail_route(_enabled: bool) -> None: def test_on_route_changed_syncs_bypass_when_controller_enables_eq() -> None: calls: list[object] = [] controller = SimpleNamespace(eq_enabled=False, routed=False) + route_switch = FakeSwitch(True) def route_system_audio(enabled: bool) -> None: calls.append(("route", enabled)) @@ -224,6 +372,7 @@ def route_system_audio(enabled: bool) -> None: fake_window = SimpleNamespace( updating_ui=False, controller=controller, + route_switch=route_switch, bypass_switch=FakeSwitch(False), update_eq_power_indicator=lambda: calls.append("power"), update_info_label=lambda: calls.append("info"), @@ -235,7 +384,7 @@ def route_system_audio(enabled: bool) -> None: set_status=lambda message: calls.append(("status", message)), notify_control_state_changed=lambda: calls.append("notify"), ) - route_switch = FakeSwitch(True) + bind_control_refresh_methods(fake_window) handled = window.MiniEqWindow.on_route_changed(fake_window, route_switch, None) @@ -260,6 +409,7 @@ def route_system_audio(enabled: bool) -> None: def test_on_bypass_changed_resets_switch_when_engine_update_fails() -> None: calls: list[object] = [] + bypass_switch = FakeSwitch(False) def fail_enabled(_enabled: bool) -> None: raise RuntimeError("control update failed") @@ -267,6 +417,8 @@ def fail_enabled(_enabled: bool) -> None: fake_window = SimpleNamespace( updating_ui=False, controller=SimpleNamespace(eq_enabled=True, set_eq_enabled=fail_enabled), + route_switch=FakeSwitch(False), + bypass_switch=bypass_switch, update_eq_power_indicator=lambda: calls.append("power"), update_info_label=lambda: calls.append("info"), update_status_summary=lambda: calls.append("summary"), @@ -276,7 +428,7 @@ def fail_enabled(_enabled: bool) -> None: set_status=lambda message: calls.append(("status", message)), notify_control_state_changed=lambda: calls.append("notify"), ) - bypass_switch = FakeSwitch(False) + bind_control_refresh_methods(fake_window) handled = window.MiniEqWindow.on_bypass_changed(fake_window, bypass_switch, None) diff --git a/tests/test_mini_eq_window_analyzer.py b/tests/test_mini_eq_window_analyzer.py index f21cbb8..020b783 100644 --- a/tests/test_mini_eq_window_analyzer.py +++ b/tests/test_mini_eq_window_analyzer.py @@ -73,12 +73,13 @@ def __init__(self, width: int, height: int) -> None: class FakeAnalyzerController: - def __init__(self) -> None: + def __init__(self, *, start_result: bool = True) -> None: self.enabled_values: list[bool] = [] + self.start_result = start_result def set_analyzer_enabled(self, enabled: bool) -> bool: self.enabled_values.append(enabled) - return True + return self.start_result class TickAnalyzerArea: @@ -95,13 +96,42 @@ def remove_tick_callback(self, callback_id: int) -> None: class AnalyzerPreviewWindow(window_analyzer.MiniEqWindowAnalyzerMixin): - def __init__(self) -> None: + def __init__(self, *, start_result: bool = True) -> None: + self.application = FakeApplication() + self.updating_ui = False self.analyzer_enabled = True + self.analyzer_frozen = False + self.analyzer_switch = FakeSwitch(True) + self.analyzer_freeze_switch = FakeSwitch(False) + self.analyzer_state_label = FakeSummaryLabel() + self.analyzer_summary_label = FakeSummaryLabel() + self.analyzer_loudness_value_label = FakeSummaryLabel() + self.analyzer_loudness_meter_area = FakeMeterArea() + self.analyzer_display_gain_db = 0.0 + self.analyzer_levels = [0.0, 0.0] + self.analyzer_loudness_snapshot = None + self.analyzer_session_max_shortterm_lufs = None self.analyzer_preview_source_id = 0 self.analyzer_preview_uses_tick_callback = False self.analyzer_preview_last_tick_time = 0.0 - self.controller = FakeAnalyzerController() + self.controller = FakeAnalyzerController(start_result=start_result) self.analyzer_area = TickAnalyzerArea() + self.graph_background_invalidations = 0 + self.graph_draws = 0 + self.analyzer_draws = 0 + + def get_application(self) -> FakeApplication: + return self.application + + def invalidate_graph_background_cache(self) -> None: + self.graph_background_invalidations += 1 + + def queue_graph_draw(self) -> None: + self.graph_draws += 1 + + def queue_analyzer_draw(self, *, force: bool = False) -> None: + del force + self.analyzer_draws += 1 class FakeFrameClock: @@ -141,26 +171,6 @@ def set_state(self, state: bool) -> None: self.state = state -class AnalyzerToggleWindow(window_analyzer.MiniEqWindowAnalyzerMixin): - def __init__(self) -> None: - self.application = FakeApplication() - self.updating_ui = False - self.analyzer_enabled = True - self.analyzer_levels = [0.8, 0.4] - self.analyzer_loudness_snapshot = analyzer.AnalyzerLoudnessSnapshot(-18.0, -17.0, -16.0) - self.analyzer_session_max_shortterm_lufs = -12.0 - self.analyzer_preview_source_id = 0 - self.analyzer_preview_uses_tick_callback = False - self.controller = FakeAnalyzerController() - self.sync_count = 0 - - def get_application(self) -> FakeApplication: - return self.application - - def sync_ui_from_state(self) -> None: - self.sync_count += 1 - - class FakeSummaryLabel: def __init__(self) -> None: self.text = "" @@ -197,6 +207,43 @@ def set_tooltip_text(self, tooltip: str) -> None: self.tooltip = tooltip +class AnalyzerToggleWindow(window_analyzer.MiniEqWindowAnalyzerMixin): + def __init__(self) -> None: + self.application = FakeApplication() + self.updating_ui = False + self.analyzer_enabled = True + self.analyzer_frozen = False + self.analyzer_switch = FakeSwitch(True) + self.analyzer_freeze_switch = FakeSwitch(False) + self.analyzer_state_label = FakeSummaryLabel() + self.analyzer_summary_label = FakeSummaryLabel() + self.analyzer_loudness_value_label = FakeSummaryLabel() + self.analyzer_loudness_meter_area = FakeMeterArea() + self.analyzer_display_gain_db = 0.0 + self.analyzer_levels = [0.8, 0.4] + self.analyzer_loudness_snapshot = analyzer.AnalyzerLoudnessSnapshot(-18.0, -17.0, -16.0) + self.analyzer_session_max_shortterm_lufs = -12.0 + self.analyzer_preview_source_id = 0 + self.analyzer_preview_uses_tick_callback = False + self.controller = FakeAnalyzerController() + self.graph_background_invalidations = 0 + self.graph_draws = 0 + self.analyzer_draws = 0 + + def get_application(self) -> FakeApplication: + return self.application + + def invalidate_graph_background_cache(self) -> None: + self.graph_background_invalidations += 1 + + def queue_graph_draw(self) -> None: + self.graph_draws += 1 + + def queue_analyzer_draw(self, *, force: bool = False) -> None: + del force + self.analyzer_draws += 1 + + class AnalyzerSummaryWindow(window_analyzer.MiniEqWindowAnalyzerMixin): def __init__(self) -> None: self.analyzer_summary_label = FakeSummaryLabel() @@ -355,6 +402,21 @@ def test_analyzer_preview_uses_frame_clock_tick_callback() -> None: assert window.analyzer_preview_uses_tick_callback is False +def test_analyzer_preview_failure_refreshes_monitor_controls() -> None: + window = AnalyzerPreviewWindow(start_result=False) + + window.start_analyzer_preview() + + assert window.controller.enabled_values == [True] + assert window.analyzer_enabled is False + assert window.analyzer_switch.get_state() is False + assert window.analyzer_state_label.text == "Off" + assert window.graph_background_invalidations == 1 + assert window.graph_draws == 1 + assert window.analyzer_draws == 1 + assert window.application.state_count == 1 + + def test_analyzer_preview_frame_coalesces_to_30hz() -> None: window = PreviewFrameWindow() @@ -374,7 +436,9 @@ def test_analyzer_toggle_off_emits_zero_level_signal(monkeypatch) -> None: handled = window.on_analyzer_changed(monitor_switch, None) assert handled is True - assert monitor_switch.get_state() is False + assert window.analyzer_switch.get_state() is False + assert window.analyzer_state_label.text == "Off" + assert window.analyzer_summary_label.text == "Off" assert window.controller.enabled_values == [False] assert window.analyzer_enabled is False assert window.analyzer_levels == [0.0, 0.0] @@ -382,10 +446,29 @@ def test_analyzer_toggle_off_emits_zero_level_signal(monkeypatch) -> None: assert window.analyzer_session_max_shortterm_lufs is None assert window.application.analyzer_count == 1 assert window.application.state_count == 1 - assert window.sync_count == 1 + assert window.graph_background_invalidations == 1 + assert window.graph_draws == 1 + assert window.analyzer_draws == 2 assert saved_values == [False] +def test_analyzer_freeze_refreshes_monitor_controls() -> None: + window = AnalyzerToggleWindow() + freeze_switch = FakeSwitch(True) + + handled = window.on_analyzer_freeze_changed(freeze_switch, None) + + assert handled is True + assert window.analyzer_frozen is True + assert window.analyzer_freeze_switch.get_state() is True + assert window.analyzer_state_label.text == "Frozen" + assert window.analyzer_summary_label.text == "Frozen · -17.0 LUFS" + assert window.application.state_count == 1 + assert window.graph_background_invalidations == 0 + assert window.graph_draws == 0 + assert window.analyzer_draws == 1 + + def test_analyzer_summary_prefers_live_shortterm_loudness() -> None: window = AnalyzerSummaryWindow() diff --git a/tests/test_mini_eq_window_graph.py b/tests/test_mini_eq_window_graph.py index 4a68586..eb0e107 100644 --- a/tests/test_mini_eq_window_graph.py +++ b/tests/test_mini_eq_window_graph.py @@ -13,6 +13,7 @@ def __init__(self) -> None: self.text = "" self.tooltip = "" self.visible = True + self.css_classes: set[str] = set() def set_text(self, text: str) -> None: self.text = text @@ -23,14 +24,24 @@ def set_tooltip_text(self, text: str) -> None: def set_visible(self, visible: bool) -> None: self.visible = visible + def add_css_class(self, css_class: str) -> None: + self.css_classes.add(css_class) + + def remove_css_class(self, css_class: str) -> None: + self.css_classes.discard(css_class) + class FakeSwitch: def __init__(self, active: bool) -> None: self.active = active + self.sensitive = True def get_active(self) -> bool: return self.active + def set_sensitive(self, sensitive: bool) -> None: + self.sensitive = sensitive + class FakeScale: def __init__(self, value: float) -> None: @@ -41,14 +52,25 @@ def get_value(self) -> float: class FocusSummaryWindow(window_graph.MiniEqWindowGraphMixin): - def __init__(self, *, route_active: bool, selected_band_index: int | None = 0) -> None: + def __init__( + self, + *, + route_active: bool, + selected_band_index: int | None = 0, + controller_routed: bool | None = None, + eq_enabled: bool = True, + ) -> None: self.selected_band_index = selected_band_index - self.controller = type( - "Controller", - (), - {"bands": [core.EqBand(core.FILTER_TYPES["Bell"], 32.0, gain_db=1.8)]}, - )() + controller_state = { + "bands": [core.EqBand(core.FILTER_TYPES["Bell"], 32.0, gain_db=1.8)], + "eq_enabled": eq_enabled, + } + if controller_routed is not None: + controller_state["routed"] = controller_routed + self.controller = type("Controller", (), controller_state)() self.route_switch = FakeSwitch(route_active) + self.bypass_switch = FakeSwitch(False) + self.bypass_state_label = FakeLabel() self.focus_label = FakeLabel() self.band_count_label = FakeLabel() self.inspector_summary_label = FakeLabel() @@ -70,6 +92,24 @@ def test_focus_summary_keeps_selected_band_visible_when_system_eq_is_off() -> No assert "System-wide EQ is off." in window.focus_label.tooltip +def test_focus_summary_uses_controller_route_state_over_stale_switch() -> None: + window = FocusSummaryWindow(route_active=False, controller_routed=True) + + window.update_focus_summary() + + assert "System-wide EQ is off." not in window.focus_label.tooltip + + +def test_compare_state_uses_controller_route_state_over_stale_switch() -> None: + window = FocusSummaryWindow(route_active=False, controller_routed=True, eq_enabled=True) + + window.update_eq_power_indicator() + + assert window.bypass_switch.sensitive is True + assert window.bypass_state_label.text == "Equalized" + assert "compare-state-equalized" in window.bypass_state_label.css_classes + + def test_focus_summary_handles_no_selected_band() -> None: window = FocusSummaryWindow(route_active=True, selected_band_index=None) @@ -96,3 +136,39 @@ def test_preamp_change_refreshes_preset_metadata() -> None: assert test_window.preamp_label.text == "-3.5 dB" assert calls == [("preamp", -3.5), "invalidate", "draw", "metadata"] + + +def test_curve_metadata_refresh_updates_preset_state_immediately(monkeypatch) -> None: + calls: list[object] = [] + test_window = SimpleNamespace( + curve_metadata_refresh_source_id=0, + ui_shutting_down=False, + update_preset_state=lambda: calls.append("preset-state"), + on_curve_metadata_refresh_idle=lambda: False, + ) + monkeypatch.setattr( + window_graph.GLib, + "idle_add", + lambda callback: calls.append(("idle", callback)) or 42, + ) + + window_graph.MiniEqWindowGraphMixin.schedule_curve_metadata_refresh(test_window) + + assert calls == [("preset-state"), ("idle", test_window.on_curve_metadata_refresh_idle)] + assert test_window.curve_metadata_refresh_source_id == 42 + + +def test_curve_metadata_refresh_updates_preset_state_with_pending_idle(monkeypatch) -> None: + calls: list[object] = [] + test_window = SimpleNamespace( + curve_metadata_refresh_source_id=42, + ui_shutting_down=False, + update_preset_state=lambda: calls.append("preset-state"), + on_curve_metadata_refresh_idle=lambda: False, + ) + monkeypatch.setattr(window_graph.GLib, "idle_add", lambda _callback: calls.append("unexpected-idle")) + + window_graph.MiniEqWindowGraphMixin.schedule_curve_metadata_refresh(test_window) + + assert calls == ["preset-state"] + assert test_window.curve_metadata_refresh_source_id == 42 diff --git a/tests/test_mini_eq_wireplumber_stream_router.py b/tests/test_mini_eq_wireplumber_stream_router.py deleted file mode 100644 index 3ad6a4e..0000000 --- a/tests/test_mini_eq_wireplumber_stream_router.py +++ /dev/null @@ -1,294 +0,0 @@ -from __future__ import annotations - -import pytest - -from tests._mini_eq_imports import wireplumber_backend as wp_backend -from tests._mini_eq_imports import wireplumber_stream_router as wp_router - - -def make_node( - bound_id: int, - media_class: str, - node_name: str, - application_name: str | None = None, - properties: dict[str, str] | None = None, -) -> wp_backend.WirePlumberNode: - return wp_backend.WirePlumberNode( - bound_id=bound_id, - object_serial=str(bound_id + 1000), - media_class=media_class, - node_name=node_name, - node_description=None, - application_name=application_name, - node_dont_move=False, - properties=properties or {}, - ) - - -class FakeWirePlumberBackend: - def __init__( - self, - streams: list[wp_backend.WirePlumberNode], - target_nodes: dict[int, str] | None = None, - ) -> None: - self.streams = streams - self.target_nodes = target_nodes or {} - self.moves: list[tuple[int, str]] = [] - self.connected = False - self.closed = False - self.disconnected_handlers: list[int] = [] - self.missing_stream_ids: set[int] = set() - self.move_failures: dict[int, Exception] = {} - - def connect(self) -> None: - self.connected = True - - def close(self) -> None: - self.closed = True - - def list_output_streams(self) -> list[wp_backend.WirePlumberNode]: - return self.streams - - def move_stream_to_target(self, stream_bound_id: int, target_node_name: str) -> None: - if stream_bound_id in self.missing_stream_ids: - raise wp_backend.WirePlumberError(f"output stream not found: {stream_bound_id}") - if stream_bound_id in self.move_failures: - raise self.move_failures[stream_bound_id] - - self.moves.append((stream_bound_id, target_node_name)) - self.target_nodes[stream_bound_id] = target_node_name - - def node_from_proxy(self, node): - return node - - def connect_object_added(self, _callback) -> int: - return 42 - - def disconnect_node_manager_handler(self, handler_id: int) -> None: - self.disconnected_handlers.append(handler_id) - - -def test_wireplumber_router_moves_only_external_output_streams() -> None: - backend = FakeWirePlumberBackend( - [ - make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), - make_node(2, wp_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_output"), - make_node(3, wp_backend.STREAM_OUTPUT_AUDIO, "control", wp_router.OUTPUT_CLIENT_NAME), - make_node(4, wp_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_1_output"), - ] - ) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - - routed_now = router.route_output_streams() - - assert routed_now == 1 - assert backend.moves == [(1, "mini_eq_sink")] - assert router.routed_stream_ids == {1} - - -def test_wireplumber_router_skips_notification_and_system_event_streams() -> None: - backend = FakeWirePlumberBackend( - [ - make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), - make_node(2, wp_backend.STREAM_OUTPUT_AUDIO, "bell", "libcanberra", {"media.role": "event"}), - make_node(3, wp_backend.STREAM_OUTPUT_AUDIO, "GNOME Shell", "GNOME Shell"), - ] - ) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - - routed_now = router.route_output_streams() - - assert routed_now == 1 - assert backend.moves == [(1, "mini_eq_sink")] - assert router.routed_stream_ids == {1} - - -def test_wireplumber_router_restores_tracked_external_streams() -> None: - backend = FakeWirePlumberBackend( - [ - make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), - make_node(2, wp_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_output"), - ] - ) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - router.set_output_sink_name("speakers") - router.routed_stream_ids = {1, 2, 99} - - restored = router.restore_output_streams() - - assert restored == 1 - assert backend.moves == [(1, "speakers")] - assert router.routed_stream_ids == set() - - -def test_wireplumber_router_rewrites_tracked_route_target_without_metadata_readback() -> None: - backend = FakeWirePlumberBackend( - [make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], - {1: "mini_eq_sink"}, - ) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - router.routed_stream_ids = {1} - - routed_now = router.route_output_streams() - - assert routed_now == 0 - assert backend.moves == [(1, "mini_eq_sink")] - assert router.routed_stream_ids == {1} - - -def test_wireplumber_router_routes_without_target_metadata_preflight() -> None: - backend = FakeWirePlumberBackend( - [make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], - {1: "mini_eq_sink"}, - ) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - - routed_now = router.route_output_streams() - - assert routed_now == 1 - assert backend.moves == [(1, "mini_eq_sink")] - assert router.routed_stream_ids == {1} - - -def test_wireplumber_router_drops_stream_that_disappears_during_route() -> None: - backend = FakeWirePlumberBackend( - [make_node(92, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], - ) - backend.missing_stream_ids = {92} - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - router.routed_stream_ids = {92} - - routed_now = router.route_output_streams() - - assert routed_now == 0 - assert backend.moves == [] - assert router.routed_stream_ids == set() - - -def test_wireplumber_router_enable_raises_and_stops_monitoring_on_initial_route_error() -> None: - backend = FakeWirePlumberBackend( - [make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], - ) - statuses: list[str] = [] - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", statuses.append, backend) - - def fail_move(_stream_bound_id: int, _target_node_name: str) -> None: - raise RuntimeError("metadata permission denied") - - backend.move_stream_to_target = fail_move - - with pytest.raises(RuntimeError, match="metadata permission denied"): - router.enable() - - assert router.enabled is False - assert router.accept_stream_events is False - assert backend.disconnected_handlers == [42] - assert statuses == ["routing warning: metadata permission denied"] - - -def test_wireplumber_router_enable_restores_partial_initial_route_failure() -> None: - backend = FakeWirePlumberBackend( - [ - make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify"), - make_node(2, wp_backend.STREAM_OUTPUT_AUDIO, "browser", "Browser"), - ] - ) - backend.move_failures = {2: RuntimeError("metadata permission denied")} - statuses: list[str] = [] - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", statuses.append, backend) - router.set_output_sink_name("speakers") - - with pytest.raises(RuntimeError, match="metadata permission denied"): - router.enable() - - assert router.enabled is False - assert router.routed_stream_ids == set() - assert backend.moves == [(1, "mini_eq_sink"), (1, "speakers")] - assert backend.target_nodes[1] == "speakers" - assert statuses == ["routing warning: metadata permission denied"] - - -def test_wireplumber_router_always_writes_restore_move_for_tracked_streams() -> None: - backend = FakeWirePlumberBackend( - [make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], - {1: "speakers"}, - ) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - router.set_output_sink_name("speakers") - router.routed_stream_ids = {1} - - restored = router.restore_output_streams() - - assert restored == 1 - assert backend.moves == [(1, "speakers")] - assert router.routed_stream_ids == set() - - -def test_wireplumber_router_drops_stream_that_disappears_during_restore() -> None: - backend = FakeWirePlumberBackend( - [make_node(92, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")], - ) - backend.missing_stream_ids = {92} - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - router.set_output_sink_name("speakers") - router.routed_stream_ids = {92} - - restored = router.restore_output_streams() - - assert restored == 0 - assert backend.moves == [] - assert router.routed_stream_ids == set() - - -def test_wireplumber_router_schedules_one_refresh_for_new_output_stream(monkeypatch: pytest.MonkeyPatch) -> None: - backend = FakeWirePlumberBackend([]) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - scheduled_callbacks: list[object] = [] - - monkeypatch.setattr( - wp_router.GLib, - "idle_add", - lambda callback: scheduled_callbacks.append(callback) or 321, - ) - - router.accept_stream_events = True - stream = make_node(1, wp_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify") - router.handle_object_added(None, stream) - router.handle_object_added(None, stream) - - assert router.event_source_id == 321 - assert len(scheduled_callbacks) == 1 - - -def test_wireplumber_router_ignores_new_non_output_stream(monkeypatch: pytest.MonkeyPatch) -> None: - backend = FakeWirePlumberBackend([]) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - scheduled_callbacks: list[object] = [] - - monkeypatch.setattr( - wp_router.GLib, - "idle_add", - lambda callback: scheduled_callbacks.append(callback) or 321, - ) - - router.accept_stream_events = True - sink = make_node(1, wp_backend.AUDIO_SINK, "speakers") - router.handle_object_added(None, sink) - - assert router.event_source_id == 0 - assert scheduled_callbacks == [] - - -def test_wireplumber_router_close_does_not_close_shared_backend() -> None: - backend = FakeWirePlumberBackend([]) - router = wp_router.WirePlumberStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend) - - router.enable() - - assert backend.connected is True - assert router.object_added_handler_id == 42 - - router.close() - - assert backend.disconnected_handlers == [42] - assert backend.closed is False diff --git a/tests/test_release_preflight.py b/tests/test_release_preflight.py index 2e7ab56..f526a7a 100644 --- a/tests/test_release_preflight.py +++ b/tests/test_release_preflight.py @@ -46,3 +46,18 @@ def test_allowed_matches_still_cover_public_release_references() -> None: for line in lines: assert release_preflight.allowed_leak_match(line) + + +def test_pipewire_gobject_build_environment_error_lists_missing_tools(monkeypatch) -> None: + monkeypatch.setattr(release_preflight, "PIPEWIRE_GOBJECT_BUILD_TOOLS", ("definitely-missing-pwg-tool",)) + monkeypatch.setattr(release_preflight, "PIPEWIRE_GOBJECT_PKG_CONFIG_MODULES", ()) + + try: + release_preflight.check_pipewire_gobject_sdist_build_environment() + except SystemExit as error: + message = str(error) + assert "pipewire-gobject from its sdist" in message + assert "sudo apt install" in message + assert "definitely-missing-pwg-tool" in message + else: + raise AssertionError("Expected missing pipewire-gobject build tool to fail") diff --git a/tools/benchmark_fader_drag.py b/tools/benchmark_fader_drag.py index 502791c..9c7323d 100644 --- a/tools/benchmark_fader_drag.py +++ b/tools/benchmark_fader_drag.py @@ -30,8 +30,8 @@ ) from mini_eq.desktop_integration import APP_ID from mini_eq.filter_chain import builtin_biquad_band_control_values +from mini_eq.pipewire_backend import build_props_controls_param from mini_eq.window import MiniEqWindow -from mini_eq.wireplumber_backend import build_spa_params_pod @dataclass(frozen=True) @@ -52,20 +52,21 @@ def __init__(self, engine_profile: str = "none") -> None: self.engine_apply_count = 0 self.engine_control_count = 0 self.engine_pod_count = 0 - self._Wp = self.load_wireplumber_namespace() if engine_profile == "pod" else None + self._GLib = None + self._Pwg = self.load_pipewire_gobject_namespace() if engine_profile == "pod" else None - def load_wireplumber_namespace(self): - for version in ("0.5", "0.4"): - try: - gi.require_version("Wp", version) - from gi.repository import Wp + def load_pipewire_gobject_namespace(self): + try: + import pipewire_gobject # noqa: F401 - Wp.init(Wp.InitFlags.PIPEWIRE | Wp.InitFlags.SPA_TYPES) - return Wp - except (ImportError, ValueError): - continue + gi.require_version("Pwg", "0.1") + from gi.repository import GLib, Pwg - raise RuntimeError("WirePlumber GI namespace is required for --engine-profile=pod") + Pwg.init() + self._GLib = GLib + return Pwg + except (ImportError, ValueError) as exc: + raise RuntimeError("pipewire-gobject is required for --engine-profile=pod") from exc def set_band_gain(self, index: int, gain_db: float, *, apply: bool = True) -> bool: gain_db = core.clamp(gain_db, EQ_GAIN_MIN_DB, EQ_GAIN_MAX_DB) @@ -118,7 +119,7 @@ def apply_band_to_engine(self, index: int) -> None: self.engine_control_count += len(controls) if self.engine_profile == "pod": - build_spa_params_pod(self._Wp, controls) + build_props_controls_param(self._Pwg, self._GLib, controls) self.engine_pod_count += 1 @@ -357,7 +358,7 @@ def main(argv: list[str] | None = None) -> int: "--engine-profile", choices=("none", "controls", "pod"), default="none", - help="include an immediate engine-apply drag path; pod also builds a WirePlumber SPA pod", + help="include an immediate engine-apply drag path; pod also builds a Pwg Props controls param", ) parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") parser.add_argument( diff --git a/tools/check_flathub_manifest_drift.py b/tools/check_flathub_manifest_drift.py index 99c61ba..f4ac323 100755 --- a/tools/check_flathub_manifest_drift.py +++ b/tools/check_flathub_manifest_drift.py @@ -10,6 +10,7 @@ " sources:\n", " - \n", ] +SYNCED_SIBLING_FILES = ("python3-dependencies.yaml",) def mini_eq_source_block(lines: list[str], path: Path) -> tuple[int, int, list[str]]: @@ -47,21 +48,43 @@ def assert_source_kind(path: Path, source_block: list[str], expected: str) -> No raise ValueError(f"{path}: mini-eq source block is not the expected {expected} source") +def file_lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines(keepends=True) + + +def sibling_file_diffs(upstream_manifest: Path, flathub_manifest: Path) -> list[str]: + diffs: list[str] = [] + for relative_path in SYNCED_SIBLING_FILES: + upstream_file = upstream_manifest.parent / relative_path + flathub_file = flathub_manifest.parent / relative_path + upstream_lines = file_lines(upstream_file) + flathub_lines = file_lines(flathub_file) + if upstream_lines == flathub_lines: + continue + diffs.extend( + difflib.unified_diff( + upstream_lines, + flathub_lines, + fromfile=str(upstream_file), + tofile=str(flathub_file), + ) + ) + return diffs + + def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Compare the upstream and Flathub manifests while allowing the Mini EQ source stanza to differ.", ) parser.add_argument( "upstream_manifest", - nargs="?", type=Path, - default=Path("io.github.bhack.mini-eq.yaml"), + help="Path to the upstream development manifest.", ) parser.add_argument( "flathub_manifest", - nargs="?", type=Path, - default=Path("../io.github.bhack.mini-eq/io.github.bhack.mini-eq.yaml"), + help="Path to the Flathub publishing manifest.", ) return parser.parse_args(argv) @@ -74,6 +97,7 @@ def main(argv: list[str] | None = None) -> int: flathub, flathub_source = normalize_manifest(args.flathub_manifest) assert_source_kind(args.upstream_manifest, upstream_source, "local") assert_source_kind(args.flathub_manifest, flathub_source, "archive") + diffs = sibling_file_diffs(args.upstream_manifest, args.flathub_manifest) except OSError as exc: print(exc, file=sys.stderr) return 2 @@ -82,16 +106,20 @@ def main(argv: list[str] | None = None) -> int: return 2 if upstream != flathub: - diff = difflib.unified_diff( - upstream, - flathub, - fromfile=str(args.upstream_manifest), - tofile=str(args.flathub_manifest), + diffs.extend( + difflib.unified_diff( + upstream, + flathub, + fromfile=str(args.upstream_manifest), + tofile=str(args.flathub_manifest), + ) ) - sys.stdout.writelines(diff) + + if diffs: + sys.stdout.writelines(diffs) return 1 - print("Flatpak manifests match outside the expected Mini EQ source stanza.") + print("Flatpak manifests and dependency files match outside the expected Mini EQ source stanza.") return 0 diff --git a/tools/check_flatpak_runtime.py b/tools/check_flatpak_runtime.py index cdd0ae0..dec86f6 100644 --- a/tools/check_flatpak_runtime.py +++ b/tools/check_flatpak_runtime.py @@ -31,6 +31,7 @@ FULL_X86_64_STABLE_REF, ) SMOKE_APPLICATION_NAME = "mini-eq-flatpak-smoke" +SMOKE_MEDIA_ROLE = "MiniEQSmoke" SMOKE_NODE_NAME = "mini-eq-flatpak-smoke" VIRTUAL_SINK_NAME = "mini_eq_sink" PIPEWIRE_MANAGER_ACCESS = "flatpak-manager" @@ -206,10 +207,17 @@ def start_smoke_stream(target: str | None, audio_file: Path) -> subprocess.Popen command = [ "pw-cat", "--playback", - "--volume", - "0", + "--media-role", + SMOKE_MEDIA_ROLE, "--properties", - f"application.name={SMOKE_APPLICATION_NAME}", + ",".join( + [ + f"application.name={SMOKE_APPLICATION_NAME}", + f"node.name={SMOKE_NODE_NAME}", + "state.restore-props=false", + "state.restore-target=false", + ] + ), ] if target is not None: command.extend(["--target", target]) diff --git a/tools/check_live_ui_runtime.py b/tools/check_live_ui_runtime.py new file mode 100755 index 0000000..49a0e44 --- /dev/null +++ b/tools/check_live_ui_runtime.py @@ -0,0 +1,981 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import threading +import time +import wave +from collections.abc import Callable +from pathlib import Path +from typing import Any + +HELPER_SKIP_EXIT_CODE = 77 +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_FRAME_NAME = "Mini EQ" +VIRTUAL_SINK_NAME = "mini_eq_sink" +SMOKE_APPLICATION_NAME = "mini-eq-live-ui-smoke" +SMOKE_NODE_NAME = "mini-eq-live-ui-smoke" +ANALYZER_NODE_NAME = "mini-eq-analyzer" +PRIMARY_SINK_NAME = "ci_null_sink" +ALT_SINK_NAME = "ci_alt_sink" +WAIT_EVENT_NAMES = ( + "window", + "object:children-changed", + "object:property-change", + "object:state-changed", + "object:text-changed", +) +TARGET_OBJECT_RE = re.compile( + r"update: id:(?P\d+) key:'target\.object' value:'(?P[^']*)' type:'(?P[^']*)'" +) + + +def format_command(command: list[str | Path]) -> str: + return " ".join(str(part) for part in command) + + +def require_tool(name: str) -> str: + path = shutil.which(name) + if path is None: + raise RuntimeError(f"missing required tool: {name}") + return path + + +def terminate_process(process: subprocess.Popen[str] | None, label: str, timeout_seconds: float = 5.0) -> str: + if process is None: + return "" + + if process.poll() is None: + process.terminate() + try: + output, _stderr = process.communicate(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + process.kill() + output, _stderr = process.communicate(timeout=timeout_seconds) + else: + output = process.stdout.read() if process.stdout is not None and not process.stdout.closed else "" + + if output: + print(f"{label} output:\n{output.rstrip()}", flush=True) + return output or "" + + +def wait_for(label: str, predicate: Callable[[], Any], timeout_seconds: float, interval_seconds: float = 0.1) -> Any: + deadline = time.monotonic() + timeout_seconds + last_error: Exception | None = None + + while time.monotonic() < deadline: + try: + value = predicate() + except Exception as exc: + last_error = exc + else: + if value is not None and value is not False: + return value + + time.sleep(interval_seconds) + + detail = f": {last_error}" if last_error is not None else "" + raise RuntimeError(f"timed out waiting for {label}{detail}") + + +def read_pw_dump() -> list[dict[str, Any]]: + result = subprocess.run(["pw-dump"], check=True, text=True, stdout=subprocess.PIPE) + payload, _end = json.JSONDecoder().raw_decode(result.stdout.lstrip()) + if not isinstance(payload, list): + raise RuntimeError("pw-dump returned an unexpected JSON shape") + return payload + + +def item_props(item: dict[str, Any]) -> dict[str, Any]: + props = item.get("info", {}).get("props", {}) + return props if isinstance(props, dict) else {} + + +def node_items() -> list[dict[str, Any]]: + return [item for item in read_pw_dump() if item.get("type") == "PipeWire:Interface:Node"] + + +def node_by_name(node_name: str) -> dict[str, Any] | None: + for node in node_items(): + if item_props(node).get("node.name") == node_name: + return node + return None + + +def node_id(node: dict[str, Any]) -> int: + value = node.get("id") + if not isinstance(value, int): + raise RuntimeError(f"PipeWire node has no integer id: {item_props(node).get('node.name')}") + return value + + +def object_serial(node: dict[str, Any]) -> str: + serial = item_props(node).get("object.serial") + if serial is None: + raise RuntimeError(f"PipeWire node has no object.serial: {item_props(node).get('node.name')}") + return str(serial) + + +def smoke_stream_node() -> dict[str, Any] | None: + for node in node_items(): + props = item_props(node) + if props.get("media.class") == "Stream/Output/Audio" and ( + props.get("application.name") == SMOKE_APPLICATION_NAME or props.get("node.name") == SMOKE_NODE_NAME + ): + return node + return None + + +def metadata_targets() -> dict[int, tuple[str, str]]: + result = subprocess.run(["pw-metadata", "-n", "default"], check=True, text=True, stdout=subprocess.PIPE) + targets: dict[int, tuple[str, str]] = {} + + for line in result.stdout.splitlines(): + match = TARGET_OBJECT_RE.search(line) + if match is not None: + targets[int(match.group("id"))] = (match.group("value"), match.group("type")) + + return targets + + +def default_metadata_is_ready() -> bool: + result = subprocess.run( + ["pw-metadata", "-n", "default"], + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2.0, + check=False, + ) + return result.returncode == 0 + + +def write_settings(config_dir: Path) -> None: + settings_dir = config_dir / "mini-eq" + settings_dir.mkdir(parents=True, exist_ok=True) + (settings_dir / "settings.json").write_text( + json.dumps({"monitor_enabled": False, "background_mode": False}) + "\n", + encoding="utf-8", + ) + + +def write_pipewire_config(config_dir: Path) -> None: + conf_dir = config_dir / "pipewire" / "pipewire.conf.d" + conf_dir.mkdir(parents=True, exist_ok=True) + (conf_dir / "10-mini-eq-live-ui-null-sinks.conf").write_text( + """ +context.objects = [ + { factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "ci_null_sink" + node.description = "CI Null Sink" + media.class = "Audio/Sink" + audio.position = "FL,FR" + adapter.auto-port-config = { + mode = dsp + monitor = true + position = preserve + } + } + } + { factory = adapter + args = { + factory.name = support.null-audio-sink + node.name = "ci_alt_sink" + node.description = "CI Alt Sink" + media.class = "Audio/Sink" + audio.position = "FL,FR" + adapter.auto-port-config = { + mode = dsp + monitor = true + position = preserve + } + } + } +] +""".strip() + + "\n", + encoding="utf-8", + ) + + +def create_sine_wav(path: Path, duration_seconds: float) -> Path: + sample_rate = 48_000 + frame_count = max(1, int(duration_seconds * sample_rate)) + amplitude = 0.18 + frequency = 440.0 + + with wave.open(str(path), "wb") as wav: + wav.setnchannels(2) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + chunk = bytearray() + for index in range(frame_count): + sample = int(32767 * amplitude * math.sin(2.0 * math.pi * frequency * index / sample_rate)) + chunk.extend(sample.to_bytes(2, "little", signed=True)) + chunk.extend(sample.to_bytes(2, "little", signed=True)) + if len(chunk) >= 192_000: + wav.writeframes(chunk) + chunk.clear() + if chunk: + wav.writeframes(chunk) + + return path + + +def start_smoke_stream(audio_file: Path) -> subprocess.Popen[str]: + command = [ + "pw-cat", + "--playback", + "--media-role", + "Music", + "--target", + PRIMARY_SINK_NAME, + "--properties", + ",".join( + [ + f"application.name={SMOKE_APPLICATION_NAME}", + f"node.name={SMOKE_NODE_NAME}", + "state.restore-props=false", + "state.restore-target=false", + ] + ), + str(audio_file), + ] + print(f"$ {format_command(command)}", flush=True) + return subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + +def start_accessible_event_loop(pyatspi) -> threading.Thread: + accessible_event.clear() + pyatspi.Registry.registerEventListener(on_accessible_event, *WAIT_EVENT_NAMES) + event_thread = threading.Thread(target=pyatspi.Registry.start, name="mini-eq-live-ui-atspi", daemon=True) + event_thread.start() + return event_thread + + +def stop_accessible_event_loop(pyatspi, event_thread: threading.Thread | None) -> None: + if event_thread is None: + return + try: + pyatspi.Registry.deregisterEventListener(on_accessible_event, *WAIT_EVENT_NAMES) + except Exception: + pass + pyatspi.Registry.stop() + event_thread.join(timeout=2.0) + + +accessible_event = threading.Event() + + +def on_accessible_event(_event) -> None: + accessible_event.set() + + +def iter_accessibles(root): + stack = [root] + visited = 0 + while stack and visited < 6000: + node = stack.pop() + visited += 1 + yield node + + try: + child_count = node.childCount + except Exception: + child_count = 0 + + for index in reversed(range(min(child_count, 700))): + try: + stack.append(node.getChildAtIndex(index)) + except Exception: + continue + + +def accessible_name(node) -> str: + try: + return node.name or "" + except Exception: + return "" + + +def accessible_role(node) -> str: + try: + return node.getRoleName() + except Exception: + return "" + + +def state_contains(node, state) -> bool: + try: + return node.getState().contains(state) + except Exception: + return False + + +def find_accessible(root, pyatspi, *, name: str, role: str | None = None, showing: bool | None = None): + for node in iter_accessibles(root): + if accessible_name(node) != name: + continue + if role is not None and accessible_role(node) != role: + continue + if showing is not None and state_contains(node, pyatspi.STATE_SHOWING) != showing: + continue + return node + return None + + +def find_accessible_with_roles(root, pyatspi, *, name: str, roles: set[str], showing: bool | None = None): + for node in iter_accessibles(root): + if accessible_name(node) != name: + continue + if accessible_role(node) not in roles: + continue + if showing is not None and state_contains(node, pyatspi.STATE_SHOWING) != showing: + continue + return node + return None + + +def snapshot_frames(root, pyatspi) -> list[tuple[str, str, bool]]: + rows = [] + for node in iter_accessibles(root): + role = accessible_role(node) + if role in {"application", "frame"}: + rows.append((role, accessible_name(node), state_contains(node, pyatspi.STATE_SHOWING))) + return rows + + +def snapshot_showing_controls(root, pyatspi, limit: int = 120) -> list[tuple[str, str]]: + rows = [] + interesting_roles = { + "combo box", + "label", + "list item", + "menu item", + "push button", + "slider", + "spin button", + "status bar", + "switch", + "text", + "toggle button", + } + for node in iter_accessibles(root): + if not state_contains(node, pyatspi.STATE_SHOWING): + continue + role = accessible_role(node) + name = accessible_name(node) + if role not in interesting_roles or not name: + continue + rows.append((role, name)) + if len(rows) >= limit: + return rows + return rows + + +class UiDriver: + def __init__(self, pyatspi, app_process: subprocess.Popen[str], app_log_path: Path, shell_log_path: Path) -> None: + self.pyatspi = pyatspi + self.app_process = app_process + self.app_log_path = app_log_path + self.shell_log_path = shell_log_path + + def desktop(self): + return self.pyatspi.Registry.getDesktop(0) + + def wait_for_accessible(self, description: str, predicate: Callable[[], Any], timeout_seconds: float) -> Any: + deadline = time.monotonic() + timeout_seconds + + while True: + value = predicate() + if value is not None and value is not False: + return value + + if self.app_process.poll() is not None: + raise AssertionError( + f"Mini EQ exited while waiting for {description}:\n{self.app_log_path.read_text(errors='replace')}" + ) + + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError( + f"Timed out waiting for {description}; frames: {snapshot_frames(self.desktop(), self.pyatspi)!r}\n" + f"Showing controls: {snapshot_showing_controls(self.desktop(), self.pyatspi)!r}\n" + f"Mini EQ log:\n{self.app_log_path.read_text(errors='replace')}\n" + f"Shell log:\n{self.shell_log_path.read_text(errors='replace')}" + ) + + accessible_event.wait(remaining) + accessible_event.clear() + + def checked(self, node) -> bool: + return state_contains(node, self.pyatspi.STATE_CHECKED) + + def sensitive(self, node) -> bool: + return state_contains(node, self.pyatspi.STATE_SENSITIVE) + + def find(self, root, *, name: str, role: str | None = None, showing: bool | None = None): + return find_accessible(root, self.pyatspi, name=name, role=role, showing=showing) + + def find_with_roles(self, root, *, name: str, roles: set[str], showing: bool | None = None): + return find_accessible_with_roles(root, self.pyatspi, name=name, roles=roles, showing=showing) + + def visible_switch_with_state(self, root, *, name: str, expected_checked: bool): + node = self.find(root, name=name, role="switch", showing=True) + if node is None or self.checked(node) != expected_checked: + return None + return node + + def status_is_visible(self, root, text: str) -> bool: + return self.find(root, name=text, role="status bar", showing=True) is not None + + def run_action(self, node, action_names: tuple[str, ...]) -> None: + try: + action = node.queryAction() + except Exception as exc: + raise AssertionError(f"{accessible_name(node)!r} does not expose AT-SPI actions") from exc + + exposed_action_names = [] + for index in range(action.nActions): + name = action.getName(index) + exposed_action_names.append(name) + if name not in action_names: + continue + if not action.doAction(index): + raise AssertionError(f"AT-SPI {name!r} action failed for {accessible_name(node)!r}") + return + + raise AssertionError( + f"{accessible_name(node)!r} does not expose one of {action_names!r}: {exposed_action_names!r}" + ) + + def activate(self, node) -> None: + try: + self.run_action(node, ("press", "click", "activate", "toggle")) + except AssertionError: + self.click(node) + + def toggle_switch(self, node) -> None: + self.run_action(node, ("toggle",)) + + def click(self, node) -> None: + try: + component = node.queryComponent() + extents = component.getExtents(self.pyatspi.DESKTOP_COORDS) + except Exception as exc: + raise AssertionError(f"{accessible_name(node)!r} does not expose an AT-SPI component") from exc + + x = int(extents.x + (extents.width / 2)) + y = int(extents.y + (extents.height / 2)) + self.pyatspi.Registry.generateMouseEvent(x, y, "b1c") + + def set_numeric_value(self, node, value: float) -> None: + try: + value_iface = node.queryValue() + except Exception as exc: + raise AssertionError(f"{accessible_name(node)!r} does not expose an AT-SPI value interface") from exc + + setter = getattr(value_iface, "setCurrentValue", None) + if callable(setter): + if setter(float(value)) is False: + raise AssertionError(f"failed to set {accessible_name(node)!r} to {value}") + return + + try: + value_iface.currentValue = float(value) + except Exception as exc: + raise AssertionError(f"failed to set {accessible_name(node)!r} to {value}") from exc + + +def wait_for_wayland_socket(runtime_dir: Path, wayland_name: str, shell: subprocess.Popen[str], log_path: Path) -> None: + socket_path = runtime_dir / wayland_name + + def socket_ready() -> bool: + if shell.poll() is not None: + raise RuntimeError(f"nested GNOME Shell exited early:\n{log_path.read_text(errors='replace')}") + return socket_path.is_socket() + + wait_for(f"nested Wayland socket {socket_path}", socket_ready, 20.0) + + +def app_log_contains(app_log_path: Path, text: str) -> bool: + return text in app_log_path.read_text(errors="replace") + + +def no_traceback(app_log_path: Path) -> bool: + return "Traceback (most recent call last)" not in app_log_path.read_text(errors="replace") + + +def wait_for_sink(name: str, timeout_seconds: float) -> dict[str, Any]: + return wait_for(name, lambda: node_by_name(name), timeout_seconds) + + +def wait_for_route_to_virtual(smoke_id: int, virtual_serial: str, timeout_seconds: float) -> None: + wait_for( + "smoke stream routed to Mini EQ virtual sink", + lambda: metadata_targets().get(smoke_id) == (virtual_serial, "Spa:Id"), + timeout_seconds, + ) + + +def wait_for_route_away_from_virtual(smoke_id: int, virtual_serial: str, timeout_seconds: float) -> None: + wait_for( + "smoke stream restored away from Mini EQ virtual sink", + lambda: metadata_targets().get(smoke_id) != (virtual_serial, "Spa:Id"), + timeout_seconds, + ) + + +def start_nested_shell(runtime_dir: Path, wayland_name: str, log_path: Path) -> subprocess.Popen[str]: + gnome_shell = require_tool("gnome-shell") + shell_log = log_path.open("w", encoding="utf-8") + process = subprocess.Popen( + [ + gnome_shell, + "--headless", + "--wayland", + "--no-x11", + "--virtual-monitor", + "1600x900", + "--wayland-display", + wayland_name, + ], + stdout=shell_log, + stderr=subprocess.STDOUT, + text=True, + ) + shell_log.close() + wait_for_wayland_socket(runtime_dir, wayland_name, process, log_path) + return process + + +def start_app(repo_root: Path, wayland_name: str, app_log_path: Path) -> subprocess.Popen[str]: + env = os.environ.copy() + src_path = str(repo_root / "src") + env["PYTHONPATH"] = f"{src_path}{os.pathsep}{env['PYTHONPATH']}" if env.get("PYTHONPATH") else src_path + env["GSETTINGS_BACKEND"] = "memory" + env["GTK_A11Y"] = "atspi" + env["GDK_BACKEND"] = "wayland" + env["WAYLAND_DISPLAY"] = wayland_name + env.pop("DISPLAY", None) + app_log = app_log_path.open("w", encoding="utf-8") + process = subprocess.Popen( + [sys.executable, "-m", "mini_eq", "--auto-route", "--output-sink", PRIMARY_SINK_NAME], + cwd=repo_root, + env=env, + stdout=app_log, + stderr=subprocess.STDOUT, + text=True, + ) + app_log.close() + return process + + +def choose_dropdown_option( + driver: UiDriver, + frame, + *, + combo_name: str, + option_name: str, + timeout_seconds: float, +) -> None: + dropdown_timeout = min(timeout_seconds, 5.0) + combo = driver.wait_for_accessible( + f"{combo_name} combo", + lambda: driver.find_with_roles( + frame, + name=combo_name, + roles={"combo box", "push button", "toggle button"}, + showing=True, + ), + dropdown_timeout, + ) + driver.activate(combo) + option = driver.wait_for_accessible( + f"{option_name} dropdown option", + lambda: driver.find_with_roles( + driver.desktop(), + name=option_name, + roles={"menu item", "list item", "push button", "toggle button", "label"}, + showing=True, + ), + dropdown_timeout, + ) + driver.activate(option) + + +def run_ui_flow( + *, + pyatspi, + repo_root: Path, + runtime_dir: Path, + tmp_dir: Path, + timeout_seconds: float, + cycles: int, + audio_duration: float, +) -> None: + shell_log_path = tmp_dir / "gnome-shell.log" + app_log_path = tmp_dir / "mini-eq.log" + wayland_name = f"mini-eq-live-ui-{os.getpid()}" + shell: subprocess.Popen[str] | None = None + app: subprocess.Popen[str] | None = None + smoke: subprocess.Popen[str] | None = None + event_thread: threading.Thread | None = None + output_switch_verified = False + + try: + audio_file = create_sine_wav(tmp_dir / "mini-eq-live-ui-smoke.wav", audio_duration) + smoke = start_smoke_stream(audio_file) + smoke_node = wait_for( + "synthetic PipeWire playback stream", + lambda: smoke_stream_node() if smoke is not None and smoke.poll() is None else None, + timeout_seconds, + ) + smoke_id = node_id(smoke_node) + + shell = start_nested_shell(runtime_dir, wayland_name, shell_log_path) + app = start_app(repo_root, wayland_name, app_log_path) + event_thread = start_accessible_event_loop(pyatspi) + driver = UiDriver(pyatspi, app, app_log_path, shell_log_path) + + frame = driver.wait_for_accessible( + "Mini EQ frame", + lambda: driver.find(driver.desktop(), name=APP_FRAME_NAME, role="frame", showing=True), + timeout_seconds, + ) + route_switch = driver.wait_for_accessible( + "System-wide EQ switch", + lambda: driver.find(frame, name="System-wide EQ", role="switch", showing=True), + timeout_seconds, + ) + monitor_switch = driver.wait_for_accessible( + "Monitor switch", + lambda: driver.find(frame, name="Monitor", role="switch", showing=True), + timeout_seconds, + ) + compare_switch = driver.wait_for_accessible( + "Compare switch", + lambda: driver.find(frame, name="Compare", role="switch", showing=True), + timeout_seconds, + ) + + if not driver.sensitive(route_switch): + raise AssertionError("System-wide EQ switch is not sensitive") + if not driver.sensitive(compare_switch): + raise AssertionError("Compare switch should become sensitive when routing is active") + + route_switch = driver.wait_for_accessible( + "System-wide EQ switch to start active", + lambda: driver.visible_switch_with_state(frame, name="System-wide EQ", expected_checked=True), + timeout_seconds, + ) + driver.wait_for_accessible( + "Applied status", + lambda: driver.status_is_visible(frame, "Applied"), + timeout_seconds, + ) + + virtual_sink = wait_for_sink(VIRTUAL_SINK_NAME, timeout_seconds) + virtual_serial = object_serial(virtual_sink) + wait_for_route_to_virtual(smoke_id, virtual_serial, timeout_seconds) + + for cycle in range(cycles): + print(f"## route toggle cycle {cycle + 1}/{cycles}", flush=True) + driver.toggle_switch(route_switch) + route_switch = driver.wait_for_accessible( + "System-wide EQ switch to turn off", + lambda: driver.visible_switch_with_state(frame, name="System-wide EQ", expected_checked=False), + timeout_seconds, + ) + wait_for_route_away_from_virtual(smoke_id, virtual_serial, timeout_seconds) + + driver.toggle_switch(route_switch) + route_switch = driver.wait_for_accessible( + "System-wide EQ switch to turn on", + lambda: driver.visible_switch_with_state(frame, name="System-wide EQ", expected_checked=True), + timeout_seconds, + ) + wait_for_route_to_virtual(smoke_id, virtual_serial, timeout_seconds) + + try: + choose_dropdown_option( + driver, + frame, + combo_name="EQ output", + option_name="CI Alt Sink", + timeout_seconds=timeout_seconds, + ) + wait_for( + "Mini EQ to retarget the alternate output", + lambda: app_log_contains(app_log_path, f"-> {ALT_SINK_NAME}"), + timeout_seconds, + ) + wait_for_route_to_virtual(smoke_id, virtual_serial, timeout_seconds) + + choose_dropdown_option( + driver, + frame, + combo_name="EQ output", + option_name="CI Null Sink", + timeout_seconds=timeout_seconds, + ) + wait_for( + "Mini EQ to retarget the primary output", + lambda: app_log_contains(app_log_path, f"-> {PRIMARY_SINK_NAME}"), + timeout_seconds, + ) + wait_for_route_to_virtual(smoke_id, virtual_serial, timeout_seconds) + output_switch_verified = True + except AssertionError as exc: + print(f"Output dropdown switch was not accessible in this run: {str(exc).splitlines()[0]}", flush=True) + + if driver.checked(monitor_switch): + driver.toggle_switch(monitor_switch) + monitor_switch = driver.wait_for_accessible( + "Monitor switch to turn off before monitor cycle", + lambda: driver.visible_switch_with_state(frame, name="Monitor", expected_checked=False), + timeout_seconds, + ) + + driver.toggle_switch(monitor_switch) + monitor_switch = driver.wait_for_accessible( + "Monitor switch to turn on", + lambda: driver.visible_switch_with_state(frame, name="Monitor", expected_checked=True), + timeout_seconds, + ) + wait_for( + "Mini EQ monitor PipeWire stream", + lambda: node_by_name(ANALYZER_NODE_NAME), + timeout_seconds, + ) + bad_sources = [ + item_props(node) + for node in node_items() + if item_props(node).get("application.name") == "Mini EQ" + and item_props(node).get("media.class") == "Audio/Source" + ] + if bad_sources: + raise AssertionError(f"Monitor exposed Audio/Source nodes: {bad_sources!r}") + + gain_spin = driver.wait_for_accessible( + "Selected Band Gain spin button", + lambda: ( + driver.find_with_roles( + frame, + name="Selected Band Gain", + roles={"spin button", "text"}, + showing=True, + ) + or driver.find(frame, name="Gain", role="spin button", showing=True) + ), + timeout_seconds, + ) + driver.set_numeric_value(gain_spin, 3.0) + driver.wait_for_accessible( + "Modified preset state after band edit", + lambda: driver.status_is_visible(frame, "Modified"), + timeout_seconds, + ) + + driver.set_numeric_value(gain_spin, 0.0) + driver.wait_for_accessible( + "Modified preset state to clear after returning band gain to neutral", + lambda: not driver.status_is_visible(frame, "Modified"), + timeout_seconds, + ) + + terminate_process(smoke, "pw-cat synthetic stream") + smoke = None + wait_for("synthetic stream to disappear", lambda: smoke_stream_node() is None, timeout_seconds) + if not no_traceback(app_log_path): + raise AssertionError( + f"Mini EQ logged a traceback after stream close:\n{app_log_path.read_text(errors='replace')}" + ) + + driver.toggle_switch(monitor_switch) + driver.wait_for_accessible( + "Monitor switch to turn off", + lambda: driver.visible_switch_with_state(frame, name="Monitor", expected_checked=False), + timeout_seconds, + ) + + try: + driver.run_action(frame, ("close",)) + except AssertionError: + app.send_signal(signal.SIGINT) + + try: + app.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired as exc: + app.kill() + app.wait(timeout=2.0) + raise AssertionError("Mini EQ did not exit after close/SIGINT") from exc + + if app.returncode not in (0, -signal.SIGINT): + raise AssertionError( + f"Mini EQ exited with status {app.returncode}:\n{app_log_path.read_text(errors='replace')}" + ) + if not no_traceback(app_log_path): + raise AssertionError(f"Mini EQ logged a traceback on shutdown:\n{app_log_path.read_text(errors='replace')}") + + output_detail = "output retarget verified" if output_switch_verified else "output retarget skipped" + print( + f"Live UI runtime smoke passed: AT-SPI UI flow, synthetic stream routing, monitor, {output_detail}, and shutdown verified." + ) + finally: + stop_accessible_event_loop(pyatspi, event_thread) + terminate_process(app, "Mini EQ") + terminate_process(smoke, "pw-cat synthetic stream") + terminate_process(shell, "nested GNOME Shell") + + +def start_pipewire_processes(tmp_dir: Path) -> tuple[subprocess.Popen[str], subprocess.Popen[str]]: + pipewire_log = (tmp_dir / "pipewire.log").open("w", encoding="utf-8") + wireplumber_log = (tmp_dir / "wireplumber.log").open("w", encoding="utf-8") + pipewire = subprocess.Popen(["pipewire"], stdout=pipewire_log, stderr=subprocess.STDOUT, text=True) + wireplumber = subprocess.Popen(["wireplumber"], stdout=wireplumber_log, stderr=subprocess.STDOUT, text=True) + pipewire_log.close() + wireplumber_log.close() + return pipewire, wireplumber + + +def run_helper(_args: argparse.Namespace) -> int: + try: + import pyatspi + except Exception as exc: + print(f"pyatspi unavailable: {exc}", file=sys.stderr) + return HELPER_SKIP_EXIT_CODE + + try: + for tool in ("pipewire", "wireplumber", "pw-cat", "pw-dump", "pw-metadata", "gnome-shell"): + require_tool(tool) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return HELPER_SKIP_EXIT_CODE + + timeout_seconds = float(os.environ["MINI_EQ_LIVE_UI_TIMEOUT"]) + cycles = int(os.environ["MINI_EQ_LIVE_UI_CYCLES"]) + audio_duration = float(os.environ["MINI_EQ_LIVE_UI_AUDIO_DURATION"]) + pipewire: subprocess.Popen[str] | None = None + wireplumber: subprocess.Popen[str] | None = None + + tmp_dir = Path(tempfile.mkdtemp(prefix="mini-eq-live-ui-")) + try: + runtime_dir = tmp_dir / "runtime" + config_dir = tmp_dir / "config" + data_dir = tmp_dir / "data" + cache_dir = tmp_dir / "cache" + for directory in (runtime_dir, config_dir, data_dir, cache_dir): + directory.mkdir(parents=True, exist_ok=True) + runtime_dir.chmod(0o700) + write_settings(config_dir) + write_pipewire_config(config_dir) + + os.environ["XDG_RUNTIME_DIR"] = str(runtime_dir) + os.environ["XDG_CONFIG_HOME"] = str(config_dir) + os.environ["XDG_DATA_HOME"] = str(data_dir) + os.environ["XDG_CACHE_HOME"] = str(cache_dir) + os.environ["GSETTINGS_BACKEND"] = "memory" + + pipewire, wireplumber = start_pipewire_processes(tmp_dir) + wait_for_sink(PRIMARY_SINK_NAME, timeout_seconds) + wait_for_sink(ALT_SINK_NAME, timeout_seconds) + wait_for("WirePlumber default metadata", default_metadata_is_ready, timeout_seconds) + run_ui_flow( + pyatspi=pyatspi, + repo_root=REPO_ROOT, + runtime_dir=runtime_dir, + tmp_dir=tmp_dir, + timeout_seconds=timeout_seconds, + cycles=cycles, + audio_duration=audio_duration, + ) + finally: + terminate_process(wireplumber, "WirePlumber") + terminate_process(pipewire, "PipeWire") + shutil.rmtree(tmp_dir, ignore_errors=True) + + return 0 + + +def run_parent(args: argparse.Namespace) -> int: + try: + require_tool("dbus-run-session") + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return HELPER_SKIP_EXIT_CODE + attempts = max(1, args.retries + 1) + + for attempt in range(1, attempts + 1): + env = os.environ.copy() + env["MINI_EQ_LIVE_UI_TIMEOUT"] = str(args.timeout) + env["MINI_EQ_LIVE_UI_CYCLES"] = str(args.cycles) + env["MINI_EQ_LIVE_UI_AUDIO_DURATION"] = str(args.audio_duration) + env["PYTHONUNBUFFERED"] = "1" + env.pop("DISPLAY", None) + env.pop("WAYLAND_DISPLAY", None) + + command = [ + "dbus-run-session", + "--", + sys.executable, + str(Path(__file__).resolve()), + "--helper", + ] + print(f"$ {format_command(command)}", flush=True) + completed = subprocess.run(command, env=env, text=True) + + if completed.returncode != 139 or attempt >= attempts: + return completed.returncode + + print("Nested AT-SPI session exited with SIGSEGV; retrying once with a fresh private runtime.", flush=True) + + return 1 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Drive the real Mini EQ UI with AT-SPI against a private PipeWire session and synthetic stream.", + ) + parser.add_argument("--helper", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--timeout", type=float, default=35.0, help="Timeout for each UI or PipeWire transition.") + parser.add_argument("--cycles", type=int, default=2, help="System-wide EQ off/on cycles to drive.") + parser.add_argument("--retries", type=int, default=1, help="Retry count for native nested-session SIGSEGV exits.") + parser.add_argument( + "--audio-duration", + type=float, + default=120.0, + help="Duration of the generated sine-wave playback stream.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + try: + if args.helper: + return run_helper(args) + return run_parent(args) + except subprocess.CalledProcessError as exc: + if exc.stdout: + sys.stderr.write(exc.stdout) + return exc.returncode + except Exception as exc: + print(exc, file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_pipewire_gobject.py b/tools/check_pipewire_gobject.py new file mode 100644 index 0000000..02a53d3 --- /dev/null +++ b/tools/check_pipewire_gobject.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import argparse +import sys + +from mini_eq.pipewire_backend import build_props_controls_param + +REQUIRED_PWG_SYMBOLS = ( + "Core.new", + "Core.load_module", + "Core.set_pipewire_property", + "Global.dup_property", + "get_library_version", + "Metadata.new", + "Metadata.set", + "Node.new", + "Node.set_param", + "Param.new_props_controls", + "Registry.new", + "Registry.dup_globals_by_interface", + "Stream.new_audio_capture", + "Stream.set_deliver_audio_blocks", + "Stream.set_pipewire_property", +) + + +def resolve_symbol(root, dotted_name: str): + value = root + for part in dotted_name.split("."): + value = getattr(value, part) + return value + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Check pipewire-gobject symbols used by Mini EQ.") + parser.add_argument("--expect-version", help="fail unless the loaded Pwg library reports this version") + args = parser.parse_args(argv) + + try: + import gi + import pipewire_gobject # noqa: F401 + + gi.require_version("Pwg", "0.1") + from gi.repository import GLib, Pwg + except Exception as exc: + print(f"failed to import pipewire-gobject/Pwg: {exc}", file=sys.stderr) + return 1 + + Pwg.init() + try: + actual_version = str(Pwg.get_library_version()) + except Exception as exc: + print(f"failed to read Pwg library version: {exc}", file=sys.stderr) + return 1 + if args.expect_version and actual_version != args.expect_version: + print(f"expected Pwg {args.expect_version}, got Pwg {actual_version}", file=sys.stderr) + return 1 + + missing: list[str] = [] + for symbol in REQUIRED_PWG_SYMBOLS: + try: + resolve_symbol(Pwg, symbol) + except Exception as exc: + missing.append(f"{symbol}: {exc}") + + if missing: + print(f"Pwg {actual_version} is missing symbols required by Mini EQ:", file=sys.stderr) + for item in missing: + print(f" {item}", file=sys.stderr) + return 1 + + core = Pwg.Core.new() + props_param = build_props_controls_param(Pwg, GLib, {"eq:enabled": 1.0, "eq:g_out": 0.0}) + stream = Pwg.Stream.new_audio_capture("alsa_output.test", True) + + for label, value in ( + ("core", core), + ("Props controls param", props_param), + ("monitor capture stream", stream), + ): + if value is None: + print(f"failed to construct {label}", file=sys.stderr) + return 1 + + print(f"Pwg {actual_version} exposes the Mini EQ pipewire-gobject surface.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_wireplumber_gi.py b/tools/check_wireplumber_gi.py deleted file mode 100644 index 2b1c28f..0000000 --- a/tools/check_wireplumber_gi.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import argparse -import sys - -from mini_eq.wireplumber_backend import WirePlumberBackend, build_spa_params_pod - -REQUIRED_WP_SYMBOLS = ( - "InitFlags.PIPEWIRE", - "InitFlags.SPA_TYPES", - "Core.new", - "ObjectManager.new", - "ObjectInterest.new_type", - "Properties.new_empty", - "Node", - "Metadata", - "ProxyFeatures.PIPEWIRE_OBJECT_FEATURE_INFO", - "ProxyFeatures.PROXY_FEATURE_BOUND", - "PipewireObject.get_property", - "ImplModule.load", - "SpaPodBuilder.new_struct", - "SpaPodBuilder.new_object", -) - - -def resolve_symbol(root, dotted_name: str): - value = root - for part in dotted_name.split("."): - value = getattr(value, part) - return value - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Check WirePlumber GI symbols used by Mini EQ.") - parser.add_argument("--expect-version", help="fail unless the loaded Wp GI namespace reports this version") - args = parser.parse_args(argv) - - _glib, _gobject, Wp = WirePlumberBackend._import_wireplumber() - actual_version = str(getattr(Wp, "_version", "unknown")) - - if args.expect_version and actual_version != args.expect_version: - print(f"expected Wp {args.expect_version}, got Wp {actual_version}", file=sys.stderr) - return 1 - - missing: list[str] = [] - for symbol in REQUIRED_WP_SYMBOLS: - try: - resolve_symbol(Wp, symbol) - except Exception as exc: - missing.append(f"{symbol}: {exc}") - - if missing: - print(f"Wp {actual_version} is missing symbols required by Mini EQ:", file=sys.stderr) - for item in missing: - print(f" {item}", file=sys.stderr) - return 1 - - Wp.init(Wp.InitFlags.PIPEWIRE | Wp.InitFlags.SPA_TYPES) - backend = WirePlumberBackend() - core = backend._new_core(Wp) - node_manager = backend._build_node_manager(Wp) - metadata_manager = backend._build_metadata_manager(Wp) - props_pod = build_spa_params_pod(Wp, {"eq:enabled": 1.0, "eq:g_out": 0.0}) - - for label, value in ( - ("core", core), - ("node manager", node_manager), - ("metadata manager", metadata_manager), - ("Props pod", props_pod), - ): - if value is None: - print(f"failed to construct {label}", file=sys.stderr) - return 1 - - print(f"Wp {actual_version} exposes the Mini EQ WirePlumber GI surface.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/demo_runtime.py b/tools/demo_runtime.py index 5dff449..564182c 100644 --- a/tools/demo_runtime.py +++ b/tools/demo_runtime.py @@ -6,7 +6,7 @@ from mini_eq import core from mini_eq.analyzer import ANALYZER_BIN_COUNT, AnalyzerLoudnessSnapshot from mini_eq.core import EQ_MODES, FILTER_TYPES, PRESET_VERSION, EqBand, eq_band_to_dict -from mini_eq.wireplumber_backend import WirePlumberNode +from mini_eq.pipewire_backend import PipeWireNode DEMO_PRESET_NAME = "Studio Reference" DEMO_OUTPUT_NAME = "studio-monitor" @@ -48,7 +48,7 @@ def __init__(self) -> None: self.analyzer_loudness_callback = None self.analyzer_enabled = False self.route_enabled = False - self.demo_sink = WirePlumberNode( + self.demo_sink = PipeWireNode( bound_id=101, object_serial="101", media_class="Audio/Sink", @@ -117,13 +117,13 @@ def build_preset_payload(self, preset_name: str | None = None) -> dict[str, obje payload["name"] = preset_name return payload - def list_sinks(self) -> list[WirePlumberNode]: + def list_sinks(self) -> list[PipeWireNode]: return [self.demo_sink] def list_output_sink_names(self) -> list[str]: return [DEMO_OUTPUT_NAME] - def get_sink(self, sink_name: str | None) -> WirePlumberNode | None: + def get_sink(self, sink_name: str | None) -> PipeWireNode | None: return self.demo_sink if sink_name == DEMO_OUTPUT_NAME else None def get_default_output_sink_name(self) -> str: diff --git a/tools/release_preflight.py b/tools/release_preflight.py index f4e7130..3f2a6d2 100755 --- a/tools/release_preflight.py +++ b/tools/release_preflight.py @@ -46,6 +46,34 @@ Path("src/mini_eq/window_preferences.py"), Path("extensions/gnome-shell/mini-eq@bhack.github.io/extension.js"), ) +FLATPAK_RUNTIME_REVIEW_PATHS = ( + Path(".github/workflows/ci.yml"), + Path("io.github.bhack.mini-eq.yaml"), + Path("python3-dependencies.yaml"), + Path("pyproject.toml"), + Path("src/mini_eq/analyzer.py"), + Path("src/mini_eq/cli.py"), + Path("src/mini_eq/deps.py"), + Path("src/mini_eq/filter_chain.py"), + Path("src/mini_eq/routing.py"), + Path("src/mini_eq/window.py"), + Path("src/mini_eq/pipewire_backend.py"), + Path("src/mini_eq/pipewire_stream_router.py"), + Path("tools/check_flatpak_runtime.py"), + Path("tools/check_live_ui_runtime.py"), + Path("tools/run_flatpak_runtime_smoke_ci.sh"), + Path("tests/test_mini_eq_live_ui_runtime.py"), +) +PIPEWIRE_GOBJECT_BUILD_TOOLS = ("g-ir-compiler", "g-ir-scanner", "pkg-config") +PIPEWIRE_GOBJECT_PKG_CONFIG_MODULES = ("glib-2.0", "gio-2.0", "gobject-2.0", "libpipewire-0.3") +PIPEWIRE_GOBJECT_DEBIAN_BUILD_PACKAGES = ( + "build-essential", + "gobject-introspection", + "libgirepository1.0-dev", + "libglib2.0-dev", + "libpipewire-0.3-dev", + "pkg-config", +) def format_command(command: list[str | Path]) -> str: @@ -150,6 +178,23 @@ def run_background_portal_smoke_notice() -> None: print("Run one clean-permission Flatpak portal smoke in a real GNOME session before releasing this change.") +def run_flatpak_runtime_smoke_notice() -> None: + base_tag = extension_comparison_base_tag() + if base_tag is None: + print("\nFlatpak runtime smoke notice skipped; no release tag found.") + return + + changes = changed_paths_for_review(base_tag, FLATPAK_RUNTIME_REVIEW_PATHS) + if not changes: + print(f"\nFlatpak runtime smoke not indicated; runtime integration unchanged since {base_tag}.") + return + + print(f"\nFlatpak runtime smoke may be needed; runtime integration changed since {base_tag}:") + for path in changes: + print(f" {path}") + print("Run the installed Flatpak runtime smoke and interactive audio check before releasing this change.") + + def run_leak_scan() -> None: run(["git", "rev-list", "--count", "HEAD"]) run(["git", "ls-remote", "--heads", "origin"]) @@ -238,6 +283,46 @@ def untracked_leak_matches() -> list[str]: return matches +def missing_pkg_config_modules(modules: tuple[str, ...]) -> list[str]: + missing: list[str] = [] + for module in modules: + result = subprocess.run( + ["pkg-config", "--exists", module], + cwd=ROOT, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if result.returncode != 0: + missing.append(module) + return missing + + +def check_pipewire_gobject_sdist_build_environment() -> None: + missing_tools = [tool for tool in PIPEWIRE_GOBJECT_BUILD_TOOLS if shutil.which(tool) is None] + missing_modules = ( + list(PIPEWIRE_GOBJECT_PKG_CONFIG_MODULES) + if shutil.which("pkg-config") is None + else missing_pkg_config_modules(PIPEWIRE_GOBJECT_PKG_CONFIG_MODULES) + ) + if not missing_tools and not missing_modules: + return + + details: list[str] = [ + "The release wheel smoke installs Mini EQ in a fresh venv and must be able to build " + "pipewire-gobject from its sdist.", + "Install the PipeWire/GObject build dependencies first.", + "", + "Debian/Ubuntu:", + f" sudo apt install {' '.join(PIPEWIRE_GOBJECT_DEBIAN_BUILD_PACKAGES)}", + ] + if missing_tools: + details.append(f"Missing tools: {', '.join(missing_tools)}") + if missing_modules: + details.append(f"Missing pkg-config modules: {', '.join(missing_modules)}") + raise SystemExit("\n".join(details)) + + def run_wheel_smoke_test(python: Path, wheel: Path, scratch: Path) -> None: venv = scratch / "wheel-test" run([python, "-m", "venv", "--system-site-packages", venv]) @@ -247,6 +332,7 @@ def run_wheel_smoke_test(python: Path, wheel: Path, scratch: Path) -> None: mini_eq = bin_dir / ("mini-eq.exe" if os.name == "nt" else "mini-eq") run([venv_python, "-m", "pip", "install", "--upgrade", "pip"]) + check_pipewire_gobject_sdist_build_environment() run([venv_python, "-m", "pip", "install", wheel]) run([mini_eq, "--check-deps"]) run([mini_eq, "--help"]) @@ -268,22 +354,6 @@ def run_build_checks(python: Path) -> None: run_wheel_smoke_test(python, wheels[0], scratch) -def run_flathub_drift_check(python: Path) -> None: - flathub_manifest = ROOT.parent / "io.github.bhack.mini-eq" / "io.github.bhack.mini-eq.yaml" - if not flathub_manifest.exists(): - print("\nSkipping Flathub manifest drift check; sibling Flathub checkout not found.") - return - - run( - [ - python, - ROOT / "tools/check_flathub_manifest_drift.py", - ROOT / "io.github.bhack.mini-eq.yaml", - flathub_manifest, - ] - ) - - def main() -> int: python = Path(sys.executable) require_tools("appstreamcli", "desktop-file-validate", "git", "gnome-extensions") @@ -292,6 +362,7 @@ def main() -> int: run([python, "-m", "pytest", "tests/test_version_metadata.py", "-q"]) run([python, ROOT / "tools/check_gnome_shell_extension.py"]) run_gnome_shell_extension_upload_notice() + run_flatpak_runtime_smoke_notice() run_background_portal_smoke_notice() run([python, "-m", "ruff", "check", "."]) run([python, "-m", "ruff", "format", "--check", "."]) @@ -301,7 +372,6 @@ def main() -> int: run(["desktop-file-validate", ROOT / "data/io.github.bhack.mini-eq.desktop"]) run_build_checks(python) run_leak_scan() - run_flathub_drift_check(python) print("\nRelease preflight completed successfully.") return 0 diff --git a/tools/render_social_preview.py b/tools/render_social_preview.py index 21ad169..95d8dcc 100644 --- a/tools/render_social_preview.py +++ b/tools/render_social_preview.py @@ -18,7 +18,7 @@ ) FOOTER_LINES = ( "GTK / Libadwaita", - "WirePlumber + filter-chain", + "pipewire-gobject + filter-chain", ) diff --git a/tools/run_release_preflight_container.sh b/tools/run_release_preflight_container.sh new file mode 100755 index 0000000..a9fbc9a --- /dev/null +++ b/tools/run_release_preflight_container.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +container_cli="${CONTAINER_CLI:-docker}" +image="${MINI_EQ_PREFLIGHT_IMAGE:-mini-eq-release-preflight:trixie}" + +"$container_cli" build -f docker/preflight.Dockerfile -t "$image" . + +if [ -n "${MINI_EQ_FLATHUB_MANIFEST:-}" ]; then + flathub_manifest="$(realpath "$MINI_EQ_FLATHUB_MANIFEST")" + flathub_dir="$(dirname "$flathub_manifest")" + flathub_file="$(basename "$flathub_manifest")" + "$container_cli" run --rm \ + -v "$repo_root:/work" \ + -v "$flathub_dir:/flathub:ro" \ + -e "MINI_EQ_FLATHUB_MANIFEST=/flathub/$flathub_file" \ + -w /work \ + "$image" "$@" +else + "$container_cli" run --rm -v "$repo_root:/work" -w /work "$image" "$@" +fi