diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7efff..9d26a8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,8 @@ concurrency: cancel-in-progress: true env: - PWG_VERSION: "0.3.9" - PWG_REQUIREMENT: "pipewire-gobject>=0.3.9,<0.4" + PWG_VERSION: "0.3.10" + PWG_REQUIREMENT: "pipewire-gobject>=0.3.10,<0.4" jobs: changes: diff --git a/CHANGELOG.md b/CHANGELOG.md index c124808..97896ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.8.8 - 2026-09-17 + +- Harden single-instance lock handling against unsafe files and concurrent + startup races. +- Keep EQ frequency controls consistent across processing sample rates using + native rate-aware PipeWire biquads. +- Follow the processing clock for response curves and AutoEq requests, keeping + downloaded AutoEq profiles cached separately for each sample rate. +- Make large preset libraries searchable and scrollable in the GNOME Shell + extension. +- Improve background permission error messages. +- Update bundled PipeWire filter modules to 1.6.9 and NumPy to 2.4.6. +- Require pipewire-gobject 0.3.10 for runtime graph-clock observation. + ## 0.8.7 - 2026-06-12 - Fix saved preset loading when many presets are stored. diff --git a/README.md b/README.md index cb9f308..f3cb2eb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ 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. +The native PipeWire biquad filters calculate coefficients at their processing +sample rate; EQ processing is not pinned to 48 kHz. + ![Mini EQ screenshot](https://raw.githubusercontent.com/bhack/mini-eq/main/docs/screenshots/mini-eq.png) ## Features diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml index 0e3fa31..91ada6d 100644 --- a/data/io.github.bhack.mini-eq.metainfo.xml +++ b/data/io.github.bhack.mini-eq.metainfo.xml @@ -33,11 +33,11 @@ - https://raw.githubusercontent.com/bhack/mini-eq/v0.8.7/docs/screenshots/mini-eq.png + https://raw.githubusercontent.com/bhack/mini-eq/v0.8.8/docs/screenshots/mini-eq.png Adjust sound output with equalizer controls - https://raw.githubusercontent.com/bhack/mini-eq/v0.8.7/docs/screenshots/mini-eq-dark.png + https://raw.githubusercontent.com/bhack/mini-eq/v0.8.8/docs/screenshots/mini-eq-dark.png Use the equalizer with dark style @@ -45,6 +45,18 @@ https://github.com/bhack/mini-eq/issues https://github.com/bhack/mini-eq + + +
    +
  • Harden single-instance lock handling against unsafe files and concurrent startup races.
  • +
  • Keep EQ frequency controls consistent across processing sample rates using native rate-aware PipeWire biquads.
  • +
  • Follow the processing clock for response curves and AutoEq requests, with separate profile caches for each sample rate.
  • +
  • Make large preset libraries searchable and scrollable in the GNOME Shell extension.
  • +
  • Improve background permission error messages.
  • +
  • Update bundled PipeWire filter modules to 1.6.9 and NumPy to 2.4.6.
  • +
+
+
    diff --git a/docs/development.md b/docs/development.md index 55dd2c1..f1923ea 100644 --- a/docs/development.md +++ b/docs/development.md @@ -87,11 +87,11 @@ Install the Python package after the system packages are present: ```bash 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.9,<0.4' -w /tmp/mini-eq-wheelhouse +/tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.10,<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.9,<0.4' +~/.local/share/mini-eq/venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.10,<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 diff --git a/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js b/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js index 4e2f516..67c9713 100644 --- a/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js +++ b/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js @@ -4,6 +4,7 @@ import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import GLib from 'gi://GLib'; +import Pango from 'gi://Pango'; import Shell from 'gi://Shell'; import St from 'gi://St'; @@ -27,6 +28,10 @@ const PANEL_ANALYZER_MIN_ACTIVE_HEIGHT = 3; const PANEL_ANALYZER_ACTIVE_COLOR = 'rgba(127, 213, 232, 0.96)'; const PANEL_ANALYZER_DIM_COLOR = 'rgba(255, 255, 255, 0.24)'; const PANEL_ANALYZER_STANDBY_COLOR = 'rgba(255, 255, 255, 0.16)'; +const PRESET_SEARCH_THRESHOLD = 12; +const PRESET_PICKER_WIDTH = 280; +const PRESET_PICKER_MAX_HEIGHT = 296; +const PRESET_SEARCH_ENTRY_WIDTH = 232; const SHELL_ICON_FILE = 'mini-eq-symbolic.svg'; function unpackValue(value) { @@ -62,6 +67,12 @@ class MiniEqIndicator extends PanelMenu.Button { this._presetsSignalId = 0; this.connect('destroy', () => this._beginDispose()); this._presetItems = []; + this._allPresets = []; + this._currentPresetName = ''; + this._presetSearchEntry = null; + this._presetResultsBox = null; + this._presetFilterText = ''; + this._presetFocusSourceId = 0; this._analyzerBars = []; this._analyzerBarHeights = []; this._analyzerBarStyles = []; @@ -111,6 +122,25 @@ class MiniEqIndicator extends PanelMenu.Button { this.menu.addMenuItem(this._eqItem); this._presetsItem = new PopupMenu.PopupSubMenuMenuItem(_('Presets')); + this._presetsItem.menu.connect('open-state-changed', () => { + if (this._presetFocusSourceId) { + GLib.source_remove(this._presetFocusSourceId); + this._presetFocusSourceId = 0; + } + if (!this._presetsItem.menu.isOpen) { + this._resetPresetSearch(); + return; + } + + if (this._presetSearchEntry !== null) { + this._presetFocusSourceId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + this._presetFocusSourceId = 0; + if (!this._disposed && this._presetsItem.menu.isOpen) + this._presetSearchEntry?.grab_key_focus(); + return GLib.SOURCE_REMOVE; + }); + } + }); this.menu.addMenuItem(this._presetsItem); this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); @@ -184,6 +214,10 @@ class MiniEqIndicator extends PanelMenu.Button { return; this._disposed = true; + if (this._presetFocusSourceId) { + GLib.source_remove(this._presetFocusSourceId); + this._presetFocusSourceId = 0; + } if (this._refreshSourceId) { GLib.source_remove(this._refreshSourceId); @@ -341,8 +375,7 @@ class MiniEqIndicator extends PanelMenu.Button { const analyzerEnabled = 'analyzer_enabled' in state ? Boolean(unpackValue(state.analyzer_enabled)) : true; - const presetName = unpackValue(state.preset_name) || _('Current State'); - const curveLabel = unpackValue(state.curve_label) || presetName; + const rawPresetName = unpackValue(state.preset_name) || ''; const outputPresetName = unpackValue(state.output_preset_name) || ''; const outputPresetLabel = unpackValue(state.output_preset_label) || outputPresetName; const capabilities = unpackValue(state.capabilities) || []; @@ -353,6 +386,8 @@ class MiniEqIndicator extends PanelMenu.Button { this._routed = routed; this._eqEnabled = eqEnabled; this._analyzerEnabled = analyzerEnabled; + this._currentPresetName = rawPresetName; + this._syncPresetOrnaments(); this.visible = running; this._syncPanelStateStyle(running, routed, eqEnabled, analyzerEnabled); this._updating = true; @@ -369,7 +404,7 @@ class MiniEqIndicator extends PanelMenu.Button { this._routingItem.setSensitive(running); this._eqItem.setSensitive(running && routed); this._presetsItem.setSensitive(running); - this._presetsItem.label.text = running ? _('Curve: %s').format(curveLabel) : _('Presets'); + this._presetsItem.label.text = running ? _('Load Preset') : _('Presets'); this._statusItem.label.text = this._statusText(running, routed, eqEnabled); this._outputPresetItem.label.text = this._outputPresetText(running, outputPresetLabel); this._quitItem.visible = running && canQuit; @@ -500,22 +535,143 @@ class MiniEqIndicator extends PanelMenu.Button { } _setPresets(presets) { + this._allPresets = Array.isArray(presets) ? presets : []; + this._presetFilterText = ''; + this._rebuildPresetsMenu(); + } + + _resetPresetSearch() { + if (this._presetFilterText === '') + return; + + this._presetFilterText = ''; + if (this._presetSearchEntry !== null) + this._presetSearchEntry.text = ''; + this._refreshFilteredPresetRows(); + } + + _rebuildPresetsMenu() { this._presetsItem.menu.removeAll(); this._presetItems = []; + this._presetSearchEntry = null; + this._presetResultsBox = null; - if (!presets.length) { + if (!this._allPresets.length) { const item = new PopupMenu.PopupMenuItem(_('No saved presets')); item.setSensitive(false); this._presetsItem.menu.addMenuItem(item); return; } - for (const preset of presets) { - const item = new PopupMenu.PopupMenuItem(preset); - item.connect('activate', () => this._setPreset(preset)); - this._presetsItem.menu.addMenuItem(item); - this._presetItems.push(item); + if (this._allPresets.length <= PRESET_SEARCH_THRESHOLD) { + for (const preset of this._allPresets) + this._presetsItem.menu.addMenuItem(this._makePresetItem(preset)); + return; } + + this._buildPresetSearch(); + this._buildPresetResults(); + } + + _buildPresetSearch() { + const searchItem = new PopupMenu.PopupMenuSection(); + const searchBox = new St.BoxLayout({ + x_align: Clutter.ActorAlign.START, + x_expand: false, + }); + this._presetSearchEntry = new St.Entry({ + can_focus: true, + hint_text: _('Search presets'), + style_class: 'search-entry', + style: `width: ${PRESET_SEARCH_ENTRY_WIDTH}px;`, + x_expand: false, + track_hover: true, + }); + this._presetSearchEntry.clutter_text.connect('text-changed', () => { + this._presetFilterText = this._presetSearchEntry?.text ?? ''; + this._refreshFilteredPresetRows(); + }); + searchBox.add_child(this._presetSearchEntry); + searchItem.actor.add_child(searchBox); + this._presetsItem.menu.addMenuItem(searchItem); + } + + _buildPresetResults() { + const resultsItem = new PopupMenu.PopupMenuSection(); + const scrollView = new St.ScrollView({ + hscrollbar_policy: St.PolicyType.NEVER, + style: `width: ${PRESET_PICKER_WIDTH}px; max-height: ${PRESET_PICKER_MAX_HEIGHT}px; padding-bottom: 6px;`, + x_expand: true, + }); + this._presetResultsBox = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style: 'padding-top: 4px; padding-bottom: 4px;', + x_expand: true, + }); + scrollView.set_child(this._presetResultsBox); + resultsItem.actor.add_child(scrollView); + this._presetsItem.menu.addMenuItem(resultsItem); + this._refreshFilteredPresetRows(); + } + + _refreshFilteredPresetRows() { + if (this._presetResultsBox === null) + return; + + this._presetResultsBox.destroy_all_children(); + this._presetItems = []; + + const filteredPresets = this._filteredPresets(); + if (!filteredPresets.length) { + const item = new PopupMenu.PopupMenuItem(_('No matching presets')); + item.setSensitive(false); + this._presetResultsBox.add_child(item); + return; + } + + for (const preset of filteredPresets) + this._presetResultsBox.add_child(this._makePresetItem(preset)); + } + + _filteredPresets() { + const query = this._presetFilterText.trim().toLocaleLowerCase(); + if (!query) + return this._allPresets; + + const tokens = query.split(/\s+/).filter(Boolean); + return this._allPresets.filter(preset => { + const normalizedPreset = preset.toLocaleLowerCase(); + return tokens.every(term => normalizedPreset.includes(term)); + }); + } + + _makePresetItem(preset) { + const item = new PopupMenu.PopupMenuItem(preset); + item._miniEqPresetName = preset; + item.label.clutter_text.set({ + ellipsize: Pango.EllipsizeMode.END, + line_wrap: false, + }); + item.connect('activate', () => this._activatePreset(preset)); + this._presetItems.push(item); + this._syncPresetItemOrnament(item); + return item; + } + + _activatePreset(preset) { + this.menu.close(); + this._setPreset(preset); + } + + _syncPresetItemOrnament(item) { + item.setOrnament(item._miniEqPresetName === this._currentPresetName + ? PopupMenu.Ornament.CHECK + : PopupMenu.Ornament.NONE); + } + + _syncPresetOrnaments() { + for (const item of this._presetItems) + this._syncPresetItemOrnament(item); } }); diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml index b82d635..156e33b 100644 --- a/io.github.bhack.mini-eq.yaml +++ b/io.github.bhack.mini-eq.yaml @@ -115,8 +115,8 @@ modules: sources: - type: git url: https://gitlab.freedesktop.org/pipewire/pipewire.git - tag: 1.6.1 - commit: b7341d068947225fcdf62d39277606e8516d7f52 + tag: 1.6.9 + commit: 8fa27cabdc6c0c1350c69c026af5850ef0af1e26 - name: pipewire-gobject buildsystem: meson @@ -126,8 +126,8 @@ modules: sources: - type: git url: https://github.com/bhack/pipewire-gobject.git - tag: 0.3.9 - commit: d132598a7e03800fb169fd39d2c71afacdb47195 + tag: 0.3.10 + commit: a9d652f0f2abdba0ab7dcba12e52835f679d24a4 - python3-dependencies.yaml diff --git a/pyproject.toml b/pyproject.toml index 21411bc..d0ad586 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mini-eq" -version = "0.8.7" +version = "0.8.8" description = "Compact PipeWire system-wide parametric equalizer for Linux desktops." readme = "README.md" requires-python = ">=3.11" @@ -40,7 +40,7 @@ classifiers = [ "Topic :: Multimedia :: Sound/Audio :: Analysis", "Topic :: Multimedia :: Sound/Audio :: Mixers", ] -dependencies = ["numpy>=1.26", "pipewire-gobject>=0.3.9,<0.4"] +dependencies = ["numpy>=1.26", "pipewire-gobject>=0.3.10,<0.4"] [project.urls] Homepage = "https://github.com/bhack/mini-eq" diff --git a/python3-dependencies.yaml b/python3-dependencies.yaml index bbb22a1..23abffd 100644 --- a/python3-dependencies.yaml +++ b/python3-dependencies.yaml @@ -1,4 +1,5 @@ # Generated with flatpak-pip-generator --runtime org.gnome.Sdk//50 --yaml --output python3-dependencies --prefer-wheels=numpy numpy==2.4.4 +# Updated NumPy to 2.4.6 using CPython 3.13 wheel URLs and SHA-256 hashes from PyPI. name: python3-dependencies buildsystem: simple build-commands: [] @@ -7,15 +8,15 @@ modules: buildsystem: simple build-commands: - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" - --prefix=${FLATPAK_DEST} "numpy==2.4.4" --no-build-isolation + --prefix=${FLATPAK_DEST} "numpy==2.4.6" --no-build-isolation sources: - type: file - url: https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - sha256: c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 + url: https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + sha256: a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 only-arches: - x86_64 - type: file - url: https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl - sha256: 45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 + url: https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl + sha256: 72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b only-arches: - aarch64 diff --git a/src/mini_eq/autoeq.py b/src/mini_eq/autoeq.py index 122f2d0..5cfb07a 100644 --- a/src/mini_eq/autoeq.py +++ b/src/mini_eq/autoeq.py @@ -56,6 +56,7 @@ class AutoEqGeneratedPreset: class AutoEqDownloadedPreset: path: Path target_label: str | None = None + sample_rate: int = int(SAMPLE_RATE) def user_cache_dir() -> Path: @@ -236,9 +237,23 @@ def search_autoeq_entries(entries: list[AutoEqEntry], query: str, *, limit: int return matched[:limit] -def autoeq_download_path(entry: AutoEqEntry) -> Path: +def autoeq_sample_rate(sample_rate: float) -> int: + try: + rate = int(sample_rate) + except (ValueError, OverflowError) as exc: + raise ValueError("AutoEq sample rate must be a positive integer") from exc + if isinstance(sample_rate, bool) or rate <= 0 or rate != sample_rate: + raise ValueError("AutoEq sample rate must be a positive integer") + return rate + + +def autoeq_download_path(entry: AutoEqEntry, *, sample_rate: float = SAMPLE_RATE) -> Path: + rate = autoeq_sample_rate(sample_rate) directory = autoeq_cache_dir() / AUTOEQ_PRESET_DIR - digest = f"{int.from_bytes(hashlib.sha256(entry.cache_key.encode('utf-8')).digest()[:6], 'big'):012x}" + # Existing caches were generated at 48 kHz. Preserve that legacy key only + # for 48 kHz; other rates must never reuse those filter parameters. + key = entry.cache_key if rate == SAMPLE_RATE else f"{entry.cache_key}/fs/{rate}" + digest = f"{int.from_bytes(hashlib.sha256(key.encode('utf-8')).digest()[:6], 'big'):012x}" return directory / f"AutoEq-{digest}.txt" @@ -247,8 +262,8 @@ def autoeq_metadata_line(label: str, value: str) -> str: return f"# AutoEq {label}: {normalized}\n" if normalized else "" -def read_cached_autoeq_target_label(entry: AutoEqEntry) -> str | None: - path = autoeq_download_path(entry) +def read_cached_autoeq_target_label(entry: AutoEqEntry, *, sample_rate: float = SAMPLE_RATE) -> str | None: + path = autoeq_download_path(entry, sample_rate=sample_rate) try: lines = path.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: @@ -261,8 +276,8 @@ def read_cached_autoeq_target_label(entry: AutoEqEntry) -> str | None: return None -def cached_autoeq_target_label(entry: AutoEqEntry) -> str: - target_label = read_cached_autoeq_target_label(entry) +def cached_autoeq_target_label(entry: AutoEqEntry, *, sample_rate: float = SAMPLE_RATE) -> str: + target_label = read_cached_autoeq_target_label(entry, sample_rate=sample_rate) if target_label is not None: return target_label @@ -332,7 +347,9 @@ def autoeq_target_and_bass_boost( return target_label, bass_boost -def autoeq_equalize_body(entry: AutoEqEntry, targets: list[object]) -> dict[str, object]: +def autoeq_equalize_body( + entry: AutoEqEntry, targets: list[object], *, sample_rate: float = SAMPLE_RATE +) -> dict[str, object]: target_label, bass_boost = autoeq_target_and_bass_boost(entry, targets) return { "target": target_label, @@ -345,7 +362,7 @@ def autoeq_equalize_body(entry: AutoEqEntry, targets: list[object]) -> dict[str, "treble_boost_fc": 10000.0, "treble_boost_q": 0.7, "tilt": 0.0, - "fs": int(SAMPLE_RATE), + "fs": autoeq_sample_rate(sample_rate), "bit_depth": 16, "phase": "minimum", "f_res": 16.0, @@ -409,9 +426,11 @@ def format_autoeq_parametric_eq(parametric_eq: object) -> str: return "\n".join(lines) + "\n" -def download_autoeq_app_preset_info(entry: AutoEqEntry, *, refresh: bool = False) -> AutoEqGeneratedPreset: +def download_autoeq_app_preset_info( + entry: AutoEqEntry, *, refresh: bool = False, sample_rate: float = SAMPLE_RATE +) -> AutoEqGeneratedPreset: targets = load_autoeq_targets_data(refresh=refresh) - body = autoeq_equalize_body(entry, targets) + body = autoeq_equalize_body(entry, targets, sample_rate=sample_rate) target_label = str(body.get("target") or "Flat") data = post_json(AUTOEQ_APP_EQUALIZE_URL, body) return AutoEqGeneratedPreset( @@ -420,28 +439,35 @@ def download_autoeq_app_preset_info(entry: AutoEqEntry, *, refresh: bool = False ) -def download_autoeq_app_preset(entry: AutoEqEntry, *, refresh: bool = False) -> str: - return download_autoeq_app_preset_info(entry, refresh=refresh).text +def download_autoeq_app_preset(entry: AutoEqEntry, *, refresh: bool = False, sample_rate: float = SAMPLE_RATE) -> str: + return download_autoeq_app_preset_info(entry, refresh=refresh, sample_rate=sample_rate).text -def download_autoeq_preset(entry: AutoEqEntry, *, refresh: bool = False) -> Path: - return download_autoeq_preset_info(entry, refresh=refresh).path +def download_autoeq_preset(entry: AutoEqEntry, *, refresh: bool = False, sample_rate: float = SAMPLE_RATE) -> Path: + return download_autoeq_preset_info(entry, refresh=refresh, sample_rate=sample_rate).path def download_autoeq_preset_info( entry: AutoEqEntry, *, refresh: bool = False, + sample_rate: float = SAMPLE_RATE, ) -> AutoEqDownloadedPreset: - path = autoeq_download_path(entry) + rate = autoeq_sample_rate(sample_rate) + path = autoeq_download_path(entry, sample_rate=rate) if not refresh and path.is_file(): - return AutoEqDownloadedPreset(path=path, target_label=cached_autoeq_target_label(entry)) + return AutoEqDownloadedPreset( + path=path, target_label=cached_autoeq_target_label(entry, sample_rate=rate), sample_rate=rate + ) - generated = download_autoeq_app_preset_info(entry, refresh=refresh) + generated = download_autoeq_app_preset_info(entry, refresh=refresh, sample_rate=rate) text = generated.text if "Filter " not in text and "Preamp:" not in text: raise RuntimeError("downloaded AutoEq preset does not look like an Equalizer APO preset") path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(autoeq_metadata_line("target", generated.target_label) + text, encoding="utf-8") - return AutoEqDownloadedPreset(path=path, target_label=generated.target_label) + path.write_text( + autoeq_metadata_line("target", generated.target_label) + autoeq_metadata_line("sample rate", str(rate)) + text, + encoding="utf-8", + ) + return AutoEqDownloadedPreset(path=path, target_label=generated.target_label, sample_rate=rate) diff --git a/src/mini_eq/background.py b/src/mini_eq/background.py index f9784a8..a47b70d 100644 --- a/src/mini_eq/background.py +++ b/src/mini_eq/background.py @@ -35,6 +35,18 @@ class BackgroundPortalError(RuntimeError): pass +def user_facing_background_portal_error(error: GLib.Error) -> Exception: + unavailable_codes = ( + Gio.DBusError.UNKNOWN_METHOD, + Gio.DBusError.UNKNOWN_INTERFACE, + Gio.DBusError.SERVICE_UNKNOWN, + Gio.DBusError.NAME_HAS_NO_OWNER, + ) + if any(error.matches(Gio.dbus_error_quark(), code) for code in unavailable_codes): + return BackgroundPortalError("Background permissions are not available from this desktop portal") + return error + + def normalize_bool(value: object) -> bool: return value is True @@ -225,7 +237,7 @@ def on_request_background_done(self, connection: Gio.DBusConnection, result: Gio reply = connection.call_finish(result) (handle_path,) = reply.unpack() except GLib.Error as exc: - self.finish(False, False, exc) + self.finish(False, False, user_facing_background_portal_error(exc)) return if handle_path != self.handle_path: diff --git a/src/mini_eq/deps.py b/src/mini_eq/deps.py index eae7238..767097d 100644 --- a/src/mini_eq/deps.py +++ b/src/mini_eq/deps.py @@ -12,7 +12,7 @@ Status = Literal["ok", "missing", "warning"] -PWG_REQUIRED_VERSION = "0.3.9" +PWG_REQUIRED_VERSION = "0.3.10" PWG_REQUIRED_VERSION_PARTS = (0, 3, 9) PWG_REQUIRED_SYMBOLS = ( "Core.set_pipewire_property", @@ -39,6 +39,7 @@ "Registry.sync", "RouteInfo.new_from_param", "Stream.set_pipewire_property", + "Stream.get_graph_rate", ) PYGOBJECT_HINT = "Ubuntu/Debian: python3-gi; Fedora: python3-gobject; Arch: python-gobject" PYCAIRO_HINT = "Ubuntu/Debian: python3-cairo; Fedora: python3-cairo; Arch: python-cairo" diff --git a/src/mini_eq/filter_chain.py b/src/mini_eq/filter_chain.py index 5fa7f9e..a763088 100644 --- a/src/mini_eq/filter_chain.py +++ b/src/mini_eq/filter_chain.py @@ -3,6 +3,7 @@ from .core import ( EQ_PREAMP_MAX_DB, EQ_PREAMP_MIN_DB, + FILTER_TYPES, MAX_BANDS, OUTPUT_CLIENT_NAME, SAMPLE_RATE, @@ -10,6 +11,7 @@ BiquadCoefficients, EqBand, band_biquad_coefficients, + band_is_effective, bands_have_solo, clamp, db_to_linear, @@ -18,6 +20,95 @@ BIQUAD_CONTROL_NAMES = ("b0", "b1", "b2", "a0", "a1", "a2") BIQUAD_CONFIG_SAMPLE_RATES = (44100.0, 48000.0, 96000.0, 192000.0) +NATIVE_BIQUAD_LABELS = { + FILTER_TYPES[name]: label + for name, label in { + "Off": "bq_peaking", + "Bell": "bq_peaking", + "Hi-pass": "bq_highpass", + "Lo-pass": "bq_lowpass", + "Hi-shelf": "bq_highshelf", + "Lo-shelf": "bq_lowshelf", + "Notch": "bq_notch", + "Allpass": "bq_allpass", + "Bandpass": "bq_bandpass", + }.items() +} + + +def native_biquad_band_control_values( + index: int, + band: EqBand, + eq_enabled: bool, + sample_rate: float = SAMPLE_RATE, + solo_active: bool = False, +) -> dict[str, float]: + # The native filter computes its coefficients at the DSP clock rate. + wet = float(eq_enabled and band_is_effective(band, solo_active) and band.filter_type in NATIVE_BIQUAD_LABELS) + controls: dict[str, float] = {} + for side in ("l", "r"): + name = biquad_node_name(side, index) + controls.update( + { + f"{name}_filter:Freq": band.frequency, + f"{name}_filter:Q": band.q, + f"{name}_filter:Gain": band.gain_db, + f"{name}:Gain 1": wet, + f"{name}:Gain 2": 1.0 - wet, + } + ) + return controls + + +def native_biquad_control_values( + bands: list[EqBand], + preamp_db: float, + eq_enabled: bool, + sample_rate: float = SAMPLE_RATE, +) -> dict[str, float]: + controls = builtin_biquad_preamp_control_values(preamp_db, eq_enabled) + for index, band in enumerate(bands[:MAX_BANDS]): + controls.update(native_biquad_band_control_values(index, band, eq_enabled, sample_rate, bands_have_solo(bands))) + return controls + + +def build_native_biquad_nodes(bands: list[EqBand], preamp_db: float, eq_enabled: bool) -> str: + nodes: list[str] = [] + for side in ("l", "r"): + nodes.append(build_biquad_node(preamp_node_name(side), preamp_coefficients_by_rate(preamp_db, eq_enabled))) + for index, band in enumerate(bands): + name = biquad_node_name(side, index) + label = NATIVE_BIQUAD_LABELS.get(band.filter_type, "bq_peaking") + controls = native_biquad_band_control_values(index, band, eq_enabled, solo_active=bands_have_solo(bands)) + wet = controls[f"{name}:Gain 1"] + nodes.append(f""" {{ type = builtin name = {name}_filter label = {label} + control = {{ Freq = {spa_float(band.frequency)} Q = {spa_float(band.q)} Gain = {spa_float(band.gain_db)} }} + }} + {{ type = builtin name = {name} label = mixer + control = {{ "Gain 1" = {wet} "Gain 2" = {1.0 - wet} }} + }}""") + return "\n".join(nodes) + + +def build_native_biquad_links(band_count: int) -> str: + links: list[str] = [] + for side in ("l", "r"): + previous = preamp_node_name(side) + for index in range(band_count): + name = biquad_node_name(side, index) + links.extend( + [ + f' {{ output = "{previous}:Out" input = "{name}_filter:In" }}', + f' {{ output = "{previous}:Out" input = "{name}:In 2" }}', + f' {{ output = "{name}_filter:Out" input = "{name}:In 1" }}', + ] + ) + previous = name + return "\n".join(links) + + +def build_native_biquad_filter_chain_module_args(**kwargs) -> str: + return build_builtin_biquad_filter_chain_module_args(**kwargs, native_biquads=True) def pipewire_quote(value: str) -> str: @@ -197,11 +288,15 @@ def build_builtin_biquad_filter_chain_module_args( virtual_sink_name: str, filter_output_name: str, output_sink: str, + native_biquads: bool = False, ) -> str: graph_bands = bands[:MAX_BANDS] band_count = len(graph_bands) - nodes = build_builtin_biquad_nodes(graph_bands, preamp_db, eq_enabled) - links = build_builtin_biquad_links(band_count) + nodes = (build_native_biquad_nodes if native_biquads else build_builtin_biquad_nodes)( + graph_bands, preamp_db, eq_enabled + ) + links = (build_native_biquad_links if native_biquads else build_builtin_biquad_links)(band_count) + rate_property = "" if native_biquads else f" audio.rate = {int(SAMPLE_RATE)}\n" output_l = biquad_node_name("l", band_count - 1) if band_count else preamp_node_name("l") output_r = biquad_node_name("r", band_count - 1) if band_count else preamp_node_name("r") @@ -219,6 +314,7 @@ def build_builtin_biquad_filter_chain_module_args( outputs = [ "{output_l}:Out" "{output_r}:Out" ] }} audio.channels = 2 +{rate_property}\ audio.position = [ FL FR ] capture.props = {{ node.name = {pipewire_quote(virtual_sink_name)} diff --git a/src/mini_eq/instance.py b/src/mini_eq/instance.py index 1b75e9c..0e48441 100644 --- a/src/mini_eq/instance.py +++ b/src/mini_eq/instance.py @@ -3,6 +3,7 @@ import fcntl import os import signal +import stat import tempfile import time from collections.abc import Iterable @@ -40,17 +41,47 @@ def __init__(self, path: Path) -> None: self.handle = None def acquire(self) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - handle = self.path.open("w", encoding="utf-8") - + if not self.path.is_absolute(): + raise ValueError("Instance lock path must be absolute") + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + info = os.fstat(directory) + if info.st_uid != os.getuid() or info.st_mode & 0o022: + raise PermissionError("Instance lock directory must be owned by the user and not writable by others") + descriptor = os.open( + self.path.name, + os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, + 0o600, + dir_fd=directory, + ) + finally: + os.close(directory) + handle = os.fdopen(descriptor, "r+", encoding="utf-8") try: + info = os.fstat(handle.fileno()) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.getuid() + or info.st_nlink != 1 + or info.st_mode & 0o022 + ): + raise PermissionError("Instance lock must be a regular file owned exclusively by the user") fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as exc: handle.close() raise MiniEqAlreadyRunningError("Mini EQ is already running") from exc + except Exception: + handle.close() + raise - handle.write(f"{os.getpid()}\n") - handle.flush() + try: + handle.truncate(0) + handle.write(f"{os.getpid()}\n") + handle.flush() + except Exception: + handle.close() + raise self.handle = handle def release(self) -> None: @@ -63,10 +94,8 @@ def release(self) -> None: self.handle.close() self.handle = None - try: - self.path.unlink() - except FileNotFoundError: - pass + # Keep a stable inode: unlinking lets a concurrent opener lock an old + # inode while a third process creates and locks a different one. class MiniEqInstanceGuard: diff --git a/src/mini_eq/pipewire_backend.py b/src/mini_eq/pipewire_backend.py index 7bf6e65..5397e42 100644 --- a/src/mini_eq/pipewire_backend.py +++ b/src/mini_eq/pipewire_backend.py @@ -188,6 +188,31 @@ def __enter__(self) -> PipeWireBackend: def __exit__(self, _exc_type, _exc, _tb) -> None: self.close() + def create_graph_rate_monitor(self, sink_name: str, callback): + """Observe a sink's driving clock without keeping its graph active.""" + self._ensure_connected() + if not hasattr(self._Pwg.Stream, "get_graph_rate"): + return None + stream = self._Pwg.Stream.new_audio_capture(sink_name, True) + for key, value in ( + ("node.name", "mini-eq-clock-monitor"), + ("application.name", "Mini EQ"), + ("node.passive", "true"), + ("node.dont-move", "true"), + ("node.dont-reconnect", "true"), + ("state.restore-target", "false"), + ): + stream.set_pipewire_property(key, value) + stream.set_deliver_audio_blocks(False) + stream.connect("notify::graph-rate", lambda current, _spec: callback(current.get_graph_rate())) + try: + if not stream.start(): + raise PipeWireBackendError("failed to start graph clock monitor") + except Exception: + stream.stop() + raise + return stream + def connect(self) -> None: if self._connected: return diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py index 8c78674..6b71f43 100644 --- a/src/mini_eq/routing.py +++ b/src/mini_eq/routing.py @@ -35,11 +35,13 @@ sanitize_preset_name, ) from .filter_chain import ( - build_builtin_biquad_filter_chain_module_args, - builtin_biquad_band_control_values, - builtin_biquad_control_values, + build_native_biquad_filter_chain_module_args as build_builtin_biquad_filter_chain_module_args, +) +from .filter_chain import ( builtin_biquad_preamp_control_values, ) +from .filter_chain import native_biquad_band_control_values as builtin_biquad_band_control_values +from .filter_chain import native_biquad_control_values as builtin_biquad_control_values from .glib_utils import destroy_glib_source from .pipewire_backend import ( DEFAULT_AUDIO_SINK_KEY, @@ -47,7 +49,6 @@ NODE_PROPS_PARAM_NAME, PipeWireBackend, PipeWireNode, - node_sample_rate, parse_metadata_node_name, ) from .pipewire_routes import PipeWireOutputPresetTarget @@ -682,14 +683,39 @@ def build_default_bands(self) -> list[EqBand]: return default_eq_bands() def active_sample_rate(self) -> float: - for sink_name in (self.virtual_sink_name, self.output_sink): - rate = node_sample_rate(self.get_sink(sink_name)) - if rate > 0: - return rate + # A stopped/idle graph has no observed clock yet. Use a reference rate + # only for display/import calculations; native DSP never uses it. + return float(getattr(self, "_processing_sample_rate", 0) or SAMPLE_RATE) - return SAMPLE_RATE + def set_sample_rate_changed_callback(self, callback: Callable[[], None] | None) -> None: + self.sample_rate_changed_callback = callback + + def update_processing_sample_rate(self, rate: int) -> None: + if rate == getattr(self, "_processing_sample_rate", 0): + return + self._processing_sample_rate = rate + callback = getattr(self, "sample_rate_changed_callback", None) + if callback is not None: + callback() + + def start_graph_rate_monitor(self) -> None: + factory = getattr(self.output_backend, "create_graph_rate_monitor", None) + if factory is None or getattr(self, "_graph_rate_monitor", None) is not None: + return + try: + self._graph_rate_monitor = factory(self.virtual_sink_name, self.update_processing_sample_rate) + except Exception as exc: + self.emit_status(f"PipeWire graph clock monitor warning: {exc}") + + def stop_graph_rate_monitor(self) -> None: + monitor = getattr(self, "_graph_rate_monitor", None) + self._graph_rate_monitor = None + if monitor is not None: + monitor.stop() + self.update_processing_sample_rate(0) def build_filter_chain_module_args(self) -> str: + self._engine_band_types = tuple(band.filter_type for band in self.bands) return build_builtin_biquad_filter_chain_module_args( bands=self.bands, preamp_db=self.preamp_db, @@ -700,6 +726,7 @@ def build_filter_chain_module_args(self) -> str: ) def cancel_pending_engine_start(self) -> None: + self._engine_start_token = None watch = getattr(self, "engine_start_watch", None) self.engine_start_watch = None self.engine_start_pending = False @@ -722,8 +749,13 @@ def start_engine( self.engine_module = self.output_backend.load_filter_chain_module(self.build_filter_chain_module_args()) self.engine_start_pending = True + start_token = object() + self._engine_start_token = start_token def fail(exc: Exception) -> None: + if self._engine_start_token is not start_token: + return + self._engine_start_token = None self.engine_start_watch = None self.engine_start_pending = False self.engine_module = None @@ -738,6 +770,8 @@ def fail(exc: Exception) -> None: self.emit_status(str(exc)) def on_sink_ready(sink: PipeWireNode | None) -> None: + if self._engine_start_token is not start_token: + return self.engine_start_watch = None self.engine_start_pending = False @@ -748,12 +782,33 @@ def on_sink_ready(sink: PipeWireNode | None) -> None: fail(RuntimeError(f"filter-chain did not create {self.virtual_sink_name}")) return + # A preset/type edit during creation makes this topology obsolete. + # Replace it before announcing readiness, preserving the caller's + # completion callbacks (including restart routing restoration). + built_types = getattr(self, "_engine_band_types", None) + if built_types is not None and built_types != tuple(band.filter_type for band in self.bands): + self.stop_engine(announce=False) + try: + self.start_engine(on_ready=on_ready, on_error=on_error) + except Exception as exc: + if on_error is not None: + on_error(exc) + else: + self.emit_status(str(exc)) + return + self.filter_node_id = sink.bound_id self.running = True self.emit_status(f"filter-chain PipeWire EQ ready: {self.virtual_sink_name} -> {self.output_sink}") self.apply_state_to_engine() + if self._engine_start_token is not start_token or not self.running: + return self.start_filter_node_state_monitor() self.start_filter_control_param_monitor() + self.start_graph_rate_monitor() + if self._engine_start_token is not start_token or not self.running: + return + self._engine_start_token = None if on_ready is not None: on_ready() @@ -791,6 +846,7 @@ def on_ready() -> None: def stop_engine(self, announce: bool = True) -> None: self.cancel_pending_engine_start() + self.stop_graph_rate_monitor() module = getattr(self, "engine_module", None) if module is None: self.stop_filter_node_state_monitor() @@ -849,6 +905,13 @@ def set_filter_controls(self, controls: dict[str, float]) -> None: if self.filter_node_id is None or not self.running: return + # Native biquad labels are graph topology, not mutable controls. Rebuild + # once for a type/preset change; ordinary frequency/Q/gain edits stay live. + types = tuple(band.filter_type for band in self.bands) + if getattr(self, "_engine_band_types", types) != types: + self.restart_engine() + return + try: self.output_backend.set_node_params(self.filter_node_id, controls) except Exception as exc: @@ -1069,6 +1132,7 @@ def shutdown(self) -> None: self.outputs_changed_callback = None self.analyzer_levels_callback = None self.analyzer_loudness_callback = None + self.sample_rate_changed_callback = None try: try: diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py index ceb355c..9a23eb3 100644 --- a/src/mini_eq/window.py +++ b/src/mini_eq/window.py @@ -31,7 +31,6 @@ EQ_GAIN_MIN_DB, EQ_MODES, MODE_ORDER, - SAMPLE_RATE, AudioBackendError, ensure_preset_storage_dir, estimate_response_peak_db, @@ -51,7 +50,7 @@ from .window_presets import MiniEqWindowPresetMixin, imported_apo_curve_label, imported_apo_curve_label_for_name from .window_state import bind_window_state from .window_utility import MiniEqWindowUtilityPaneMixin -from .window_utils import requested_switch_state, set_switch_confirmed_state +from .window_utils import controller_sample_rate, requested_switch_state, set_switch_confirmed_state TOAST_TIMEOUT_SECONDS = 2 MIN_WINDOW_WIDTH = 980 @@ -269,6 +268,18 @@ def __init__( "notify::dark", self.on_style_manager_dark_changed ) self.controller.set_outputs_changed_callback(self.refresh_output_sinks) + if hasattr(self.controller, "set_sample_rate_changed_callback"): + self.controller.set_sample_rate_changed_callback(self.on_processing_sample_rate_changed) + + def on_processing_sample_rate_changed(self) -> None: + if self.ui_shutting_down: + return + self.invalidate_graph_response_cache() + self.queue_graph_draw() + self.update_status_summary() + entry = getattr(self, "autoeq_selected_entry", None) + if entry is not None and self.autoeq_dialog_is_active(): + self.schedule_autoeq_preview_load(entry) def do_size_allocate(self, width: int, height: int, baseline: int) -> None: Adw.ApplicationWindow.do_size_allocate(self, width, height, baseline) @@ -489,6 +500,8 @@ def prepare_for_shutdown(self) -> None: self.controller.set_outputs_changed_callback(None) self.controller.set_analyzer_levels_callback(None) self.controller.set_analyzer_loudness_callback(None) + if hasattr(self.controller, "set_sample_rate_changed_callback"): + self.controller.set_sample_rate_changed_callback(None) self.stop_preset_monitoring() self.stop_analyzer_preview(stop_backend=False) @@ -856,7 +869,9 @@ def profile_summary(self, sink: PipeWireNode | None) -> tuple[str, str, bool, li return f"{self.transport_label_for_sink(sink)} output", sample_text, False, warnings def estimate_curve_peak_db(self) -> float: - return estimate_response_peak_db(self.controller.bands, self.controller.preamp_db, SAMPLE_RATE) + return estimate_response_peak_db( + self.controller.bands, self.controller.preamp_db, controller_sample_rate(self.controller) + ) def update_status_summary(self) -> None: sink = self.output_sink_info() diff --git a/src/mini_eq/window_autoeq.py b/src/mini_eq/window_autoeq.py index 6a5e7f2..a4cf85f 100644 --- a/src/mini_eq/window_autoeq.py +++ b/src/mini_eq/window_autoeq.py @@ -31,7 +31,7 @@ total_response_db_at_frequencies, ) from .glib_utils import destroy_glib_source -from .window_utils import set_accessible_description, set_accessible_label +from .window_utils import controller_sample_rate, set_accessible_description, set_accessible_label AUTOEQ_PREVIEW_STEPS = 192 AUTOEQ_PREVIEW_DEBOUNCE_MS = 240 @@ -507,6 +507,7 @@ def schedule_autoeq_preview_load(self, entry: AutoEqEntry) -> None: self.autoeq_preview_request_id += 1 request_id = self.autoeq_preview_request_id + self.update_autoeq_import_button_sensitivity() self.autoeq_preview_title.set_text("Curve Preview") self.autoeq_preview_count_label.set_text("Preview") self.autoeq_preview_detail.set_text(entry.detail or "AutoEq") @@ -540,7 +541,7 @@ def start_autoeq_preview_load(self, entry: AutoEqEntry, *, request_id: int | Non def load_preview() -> None: try: - preset = download_autoeq_preset_info(entry) + preset = download_autoeq_preset_info(entry, sample_rate=sample_rate) preamp, bands = parse_apo_file(str(preset.path)) GLib.idle_add( self.finish_autoeq_preview_load, @@ -555,6 +556,10 @@ def load_preview() -> None: except Exception as exc: GLib.idle_add(self.finish_autoeq_preview_load, request_id, entry, "", 0.0, [], None, str(exc)) + # Capture on the UI thread. A rate change invalidates the request id + # and starts a new preview, never relabels an in-flight result. + sample_rate = controller_sample_rate(getattr(self, "controller", None)) + self.autoeq_preview_sample_rate = sample_rate threading.Thread(target=load_preview, daemon=True).start() def finish_autoeq_preview_load( @@ -588,7 +593,8 @@ def finish_autoeq_preview_load( f"AutoEq curve preview unavailable for {entry.name}: {error}" ) else: - self.autoeq_preview_count_label.set_text(f"{len(bands)} filters") + rate = getattr(self, "autoeq_preview_sample_rate", SAMPLE_RATE) + self.autoeq_preview_count_label.set_text(f"{len(bands)} filters · {rate / 1000:g} kHz") detail = entry.detail or "AutoEq" self.autoeq_preview_detail.set_text(self.autoeq_preview_detail_text(preamp_db, detail, target_label)) description_parts = [f"{len(bands)} filters", f"preamp {preamp_db:+.1f} dB"] @@ -675,8 +681,9 @@ def on_autoeq_preview_draw(self, _area: Gtk.DrawingArea, cr, width: int, height: frequencies: list[float] = [] response: list[float] = [] if bands and preamp_db is not None: - frequencies = stepped_response_frequencies(SAMPLE_RATE, AUTOEQ_PREVIEW_STEPS) - response = total_response_db_at_frequencies(bands, preamp_db, SAMPLE_RATE, frequencies) + rate = getattr(self, "autoeq_preview_sample_rate", SAMPLE_RATE) + frequencies = stepped_response_frequencies(rate, AUTOEQ_PREVIEW_STEPS) + response = total_response_db_at_frequencies(bands, preamp_db, rate, frequencies) db_limit = self.autoeq_preview_db_limit(response) left, right, top, bottom = self.draw_autoeq_preview_grid(cr, width_f, height_f, palette, db_limit) diff --git a/src/mini_eq/window_graph.py b/src/mini_eq/window_graph.py index f5434b6..7b645df 100644 --- a/src/mini_eq/window_graph.py +++ b/src/mini_eq/window_graph.py @@ -25,7 +25,6 @@ GRAPH_FREQ_MIN, MAX_BANDS, MODE_INDEX_BY_VALUE, - SAMPLE_RATE, EqBand, band_is_effective, bands_have_solo, @@ -34,7 +33,7 @@ total_response_db, total_response_db_at_frequencies, ) -from .window_utils import set_switch_confirmed_state +from .window_utils import controller_sample_rate, set_switch_confirmed_state ENGINE_CONTROL_REFRESH_INTERVAL_MS = 16 FOCUS_BLUE = (0.47, 0.72, 1.0) @@ -376,7 +375,12 @@ def on_graph_drag_begin(self, gesture: Gtk.GestureDrag, start_x: float, start_y: band = self.controller.bands[index] bx = self.frequency_to_x(band.frequency, width_f, left, right) by = self.db_to_y( - total_response_db(self.controller.bands, self.controller.preamp_db, SAMPLE_RATE, band.frequency), + total_response_db( + self.controller.bands, + self.controller.preamp_db, + controller_sample_rate(self.controller), + band.frequency, + ), height_f, top, bottom, @@ -473,7 +477,9 @@ def on_graph_drag_update(self, gesture: Gtk.GestureDrag, offset_x: float, offset ) for i, b in enumerate(bands) ] - db_others = total_response_db(temp_bands, self.controller.preamp_db, SAMPLE_RATE, freq) + db_others = total_response_db( + temp_bands, self.controller.preamp_db, controller_sample_rate(self.controller), freq + ) # Required gain for this band at the current mouse frequency new_gain = target_db - db_others @@ -764,6 +770,7 @@ def total_response_points( ) -> list[tuple[float, float]]: cache_key = ( self.graph_layout_key(width, height), + controller_sample_rate(self.controller), round(float(self.controller.preamp_db), 4), tuple(self.response_band_key(band) for band in self.controller.bands), ) @@ -776,7 +783,7 @@ def total_response_points( db_values = total_response_db_at_frequencies( self.controller.bands, self.controller.preamp_db, - SAMPLE_RATE, + controller_sample_rate(self.controller), frequencies, clamp_output=True, ) @@ -805,6 +812,7 @@ def selected_response_points( cache_key = ( self.graph_layout_key(width, height), + controller_sample_rate(self.controller), self.selected_band_index, self.response_band_key(selected_band), ) @@ -814,7 +822,9 @@ def selected_response_points( pixels = list(range(int(left), int(width - right))) frequencies = [self.x_to_frequency(float(pixel), width, left, right) for pixel in pixels] - db_values = total_response_db_at_frequencies([selected_band], 0.0, SAMPLE_RATE, frequencies, clamp_output=True) + db_values = total_response_db_at_frequencies( + [selected_band], 0.0, controller_sample_rate(self.controller), frequencies, clamp_output=True + ) points = [ (float(pixel), self.db_to_y(float(db_value), height, top, bottom)) for pixel, db_value in zip(pixels, db_values, strict=True) @@ -1070,7 +1080,12 @@ def draw_graph_response_overlay( band = self.controller.bands[index] x = self.frequency_to_x(band.frequency, width_f, left, right) y = self.db_to_y( - total_response_db(self.controller.bands, self.controller.preamp_db, SAMPLE_RATE, band.frequency), + total_response_db( + self.controller.bands, + self.controller.preamp_db, + controller_sample_rate(self.controller), + band.frequency, + ), height_f, top, bottom, diff --git a/src/mini_eq/window_utils.py b/src/mini_eq/window_utils.py index c75f8fb..1d6d89f 100644 --- a/src/mini_eq/window_utils.py +++ b/src/mini_eq/window_utils.py @@ -6,6 +6,13 @@ from gi.repository import Gtk, Pango +from .core import SAMPLE_RATE + + +def controller_sample_rate(controller) -> float: + getter = getattr(controller, "active_sample_rate", None) + return float(getter()) if getter is not None else SAMPLE_RATE + def set_accessible_label(widget: Gtk.Widget, label: str) -> None: widget.update_property([Gtk.AccessibleProperty.LABEL], [label]) diff --git a/tests/test_demo_runtime.py b/tests/test_demo_runtime.py new file mode 100644 index 0000000..8d3d330 --- /dev/null +++ b/tests/test_demo_runtime.py @@ -0,0 +1,13 @@ +from tools.demo_runtime import DemoController + + +def test_demo_output_transition_consumption() -> None: + controller = DemoController() + first = controller.output_preset_target_transition() + assert not first.changed + controller.output_sink = "other-demo-output" + observed = controller.output_preset_target_transition(consume=False) + assert observed.changed + assert observed.previous == first.current + assert controller.output_preset_target_transition().changed + assert not controller.output_preset_target_transition().changed diff --git a/tests/test_gnome_shell_extension.py b/tests/test_gnome_shell_extension.py index 336be7a..61556fa 100644 --- a/tests/test_gnome_shell_extension.py +++ b/tests/test_gnome_shell_extension.py @@ -23,3 +23,12 @@ def test_gnome_shell_extension_dbus_contract_matches_app() -> None: def test_gnome_shell_extension_fake_control_matches_shell_usage() -> None: assert check_gnome_shell_extension.check_fake_control_contract() is None + + +def test_gnome_shell_extension_fake_control_can_expose_large_preset_library() -> None: + fake_control = check_gnome_shell_extension.fake_control_module() + presets = fake_control.demo_presets(35) + + assert len(presets) == 35 + assert presets[:3] == ["Studio Reference", "Flat", "Voice Focus"] + assert presets[-1].startswith("Preset 35 - ") diff --git a/tests/test_mini_eq_autoeq.py b/tests/test_mini_eq_autoeq.py index 6230e37..c161233 100644 --- a/tests/test_mini_eq_autoeq.py +++ b/tests/test_mini_eq_autoeq.py @@ -26,6 +26,29 @@ def use_autoeq_cache(monkeypatch, tmp_path): return cache_dir +@pytest.mark.parametrize("rate", [44100, 48000, 96000, 192000]) +def test_autoeq_request_uses_processing_rate(rate) -> None: + assert autoeq.autoeq_equalize_body(make_entry(), [], sample_rate=rate)["fs"] == rate + + +@pytest.mark.parametrize("rate", [0, -1, True, 48000.5, float("nan"), float("inf")]) +def test_autoeq_rejects_invalid_sample_rate(rate) -> None: + with pytest.raises(ValueError, match="positive integer"): + autoeq.autoeq_equalize_body(make_entry(), [], sample_rate=rate) + + +def test_autoeq_cache_separates_rates_and_preserves_legacy(monkeypatch, tmp_path) -> None: + use_autoeq_cache(monkeypatch, tmp_path) + entry = make_entry() + legacy = autoeq.autoeq_download_path(entry) + legacy.write_text("# AutoEq target: Legacy target\nPreamp: -1 dB\n", encoding="utf-8") + assert autoeq.autoeq_download_path(entry, sample_rate=48000) == legacy + assert autoeq.download_autoeq_preset_info(entry, sample_rate=48000).target_label == "Legacy target" + paths = {autoeq.autoeq_download_path(entry, sample_rate=rate) for rate in (44100, 48000, 96000, 192000)} + assert len(paths) == 4 + assert autoeq.read_cached_autoeq_target_label(entry, sample_rate=192000) is None + + def test_parse_autoeq_app_entries_deduplicates_profiles() -> None: text = json.dumps( { @@ -144,7 +167,8 @@ def post_json(url: str, body: dict[str, object]) -> dict[str, object]: assert path.is_file() assert path.name.startswith("AutoEq-") assert path.read_text(encoding="utf-8") == ( - "# AutoEq target: Target\nPreamp: -4.62 dB\nFilter 1: ON LSC Fc 105.0 Hz Gain 3.8 dB Q 0.70\n" + "# AutoEq target: Target\n# AutoEq sample rate: 48000\n" + "Preamp: -4.62 dB\nFilter 1: ON LSC Fc 105.0 Hz Gain 3.8 dB Q 0.70\n" ) assert bodies[0]["target"] == "Target" @@ -690,6 +714,7 @@ def timeout_add(delay_ms, callback, *args): "AutoEq curve preview for Example: 0 filters, preamp -1.5 dB, target AutoEq in-ear", ] assert preview_window.autoeq_preview_detail.text == "Target: AutoEq in-ear - Preamp -1.5 dB - Source - Rig" + assert preview_window.autoeq_preview_count_label.text == "0 filters · 48 kHz" def test_preview_success_enables_import_after_target_is_visible(tmp_path) -> None: @@ -766,7 +791,7 @@ def idle_add(callback, *args): idle_calls.append((callback, args)) return len(idle_calls) - def download_autoeq_preset_info(_entry): + def download_autoeq_preset_info(_entry, *, sample_rate): raise RuntimeError("AutoEq response format changed") monkeypatch.setattr(window_autoeq.threading, "Thread", FakeThread) diff --git a/tests/test_mini_eq_background.py b/tests/test_mini_eq_background.py index 6e521ed..a8f0965 100644 --- a/tests/test_mini_eq_background.py +++ b/tests/test_mini_eq_background.py @@ -2,6 +2,8 @@ import json +from gi.repository import Gio, GLib + from tests._mini_eq_imports import core, import_mini_eq_module background = import_mini_eq_module("background") @@ -78,6 +80,25 @@ def test_background_command_can_start_active() -> None: ] +def test_unknown_method_background_portal_error_is_user_facing() -> None: + error = GLib.Error.new_literal( + Gio.dbus_error_quark(), + "No such interface org.freedesktop.portal.Background", + Gio.DBusError.UNKNOWN_METHOD, + ) + + user_error = background.user_facing_background_portal_error(error) + + assert isinstance(user_error, background.BackgroundPortalError) + assert str(user_error) == "Background permissions are not available from this desktop portal" + + +def test_other_background_portal_error_is_preserved() -> None: + error = GLib.Error.new_literal(Gio.io_error_quark(), "Connection closed", Gio.IOErrorEnum.CLOSED) + + assert background.user_facing_background_portal_error(error) is error + + def test_resolve_mini_eq_executable_prefers_path_lookup(monkeypatch) -> None: monkeypatch.setattr(background.shutil, "which", lambda name: "/usr/bin/mini-eq" if name == "mini-eq" else None) diff --git a/tests/test_mini_eq_deps.py b/tests/test_mini_eq_deps.py index d35a42c..49e0e4d 100644 --- a/tests/test_mini_eq_deps.py +++ b/tests/test_mini_eq_deps.py @@ -131,12 +131,12 @@ def test_pipewire_gobject_check_requires_current_library_version(monkeypatch) -> check = deps.check_pipewire_gobject() assert not check.ok - assert "older than required 0.3.9" in check.detail + assert "older than required 0.3.10" in check.detail def test_pipewire_gobject_check_requires_property_override_symbols(monkeypatch) -> None: fake_pwg = SimpleNamespace( - get_library_version=lambda: "0.3.9", + get_library_version=lambda: "0.3.10", Core=SimpleNamespace(), Device=SimpleNamespace( enum_all_params=object(), diff --git a/tests/test_mini_eq_filter_chain.py b/tests/test_mini_eq_filter_chain.py index 7d9621e..de43e1c 100644 --- a/tests/test_mini_eq_filter_chain.py +++ b/tests/test_mini_eq_filter_chain.py @@ -9,6 +9,42 @@ def test_pipewire_quote_escapes_module_argument_strings() -> None: assert filter_chain.pipewire_quote('a"b\\c') == '"a\\"b\\\\c"' +def test_native_biquads_follow_graph_rate_and_have_true_bypass() -> None: + bands = [core.EqBand(core.FILTER_TYPES["Lo-pass"], 1000.0, 0.0, 1.4)] + args = filter_chain.build_native_biquad_filter_chain_module_args( + bands=bands, + preamp_db=0.0, + eq_enabled=True, + virtual_sink_name="mini_eq_sink", + filter_output_name="mini_eq_sink_output", + output_sink="test", + ) + assert "audio.rate" not in args + assert "label = bq_lowpass" in args + assert 'input = "band_l_0:In 2"' in args + assert 'output = "band_l_0_filter:Out" input = "band_l_0:In 1"' in args + enabled = filter_chain.native_biquad_band_control_values(0, bands[0], True, 192000) + bypass = filter_chain.native_biquad_band_control_values(0, bands[0], False, 192000) + assert enabled["band_l_0:Gain 1"] == 1 + assert enabled["band_l_0:Gain 2"] == 0 + assert bypass["band_l_0:Gain 1"] == 0 + assert bypass["band_l_0:Gain 2"] == 1 + assert enabled["band_l_0_filter:Freq"] == 1000 + assert enabled == filter_chain.native_biquad_band_control_values(0, bands[0], True, 48000) + + +def test_native_biquad_solo_and_mute_use_dry_path() -> None: + bands = [ + core.EqBand(core.FILTER_TYPES["Bell"], 1000, 6, 1), + core.EqBand(core.FILTER_TYPES["Bell"], 2000, 3, 1, solo=True), + ] + controls = filter_chain.native_biquad_control_values(bands, 0, True) + assert controls["band_l_0:Gain 1"] == 0 + assert controls["band_l_1:Gain 1"] == 1 + bands[1].mute = True + assert filter_chain.native_biquad_control_values(bands, 0, True)["band_r_1:Gain 1"] == 0 + + def test_builtin_biquad_filter_chain_uses_pipewire_raw_biquads() -> None: bands = [ core.EqBand(core.FILTER_TYPES["Bell"], 1000.0, 6.0, 1.4), @@ -29,6 +65,7 @@ def test_builtin_biquad_filter_chain_uses_pipewire_raw_biquads() -> None: assert "type = lv2" not in args assert "plugin =" not in args assert "label = bq_raw" in args + assert "audio.rate = 48000" in args assert "name = preamp_l" in args assert "name = band_l_0" in args assert "name = band_r_1" in args diff --git a/tests/test_mini_eq_instance.py b/tests/test_mini_eq_instance.py index 9f79de6..cb6008a 100644 --- a/tests/test_mini_eq_instance.py +++ b/tests/test_mini_eq_instance.py @@ -53,4 +53,61 @@ def test_instance_lock_is_exclusive(tmp_path: Path) -> None: finally: first.release() - assert not lock_path.exists() + assert lock_path.exists() + second.acquire() + second.release() + + +def test_instance_lock_does_not_follow_symlink(tmp_path: Path) -> None: + target = tmp_path / "valuable.txt" + target.write_text("keep me") + lock_path = tmp_path / "mini-eq.lock" + lock_path.symlink_to(target) + with pytest.raises(OSError): + instance.InstanceLock(lock_path).acquire() + assert target.read_text() == "keep me" + + +def test_failed_lock_does_not_truncate_owner_pid(tmp_path: Path) -> None: + lock_path = tmp_path / "mini-eq.lock" + first = instance.InstanceLock(lock_path) + first.acquire() + try: + contents = lock_path.read_text() + with pytest.raises(instance.MiniEqAlreadyRunningError): + instance.InstanceLock(lock_path).acquire() + assert lock_path.read_text() == contents + finally: + first.release() + + +def test_instance_lock_rejects_shared_directory(tmp_path: Path) -> None: + directory = tmp_path / "shared" + directory.mkdir(mode=0o777) + directory.chmod(0o777) + with pytest.raises(PermissionError): + instance.InstanceLock(directory / "mini-eq.lock").acquire() + + +def test_instance_lock_rejects_hardlink_without_truncating(tmp_path: Path) -> None: + target = tmp_path / "valuable.txt" + target.write_text("keep me") + lock_path = tmp_path / "mini-eq.lock" + lock_path.hardlink_to(target) + with pytest.raises(PermissionError): + instance.InstanceLock(lock_path).acquire() + assert target.read_text() == "keep me" + + +def test_instance_lock_rejects_writable_file(tmp_path: Path) -> None: + lock_path = tmp_path / "mini-eq.lock" + lock_path.write_text("keep me") + lock_path.chmod(0o666) + with pytest.raises(PermissionError): + instance.InstanceLock(lock_path).acquire() + assert lock_path.read_text() == "keep me" + + +def test_instance_lock_rejects_relative_path() -> None: + with pytest.raises(ValueError, match="absolute"): + instance.InstanceLock(Path("mini-eq.lock")).acquire() diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py index c3b9bf8..691e46c 100644 --- a/tests/test_mini_eq_routing.py +++ b/tests/test_mini_eq_routing.py @@ -27,6 +27,46 @@ def make_node( ) +def test_processing_rate_notifications_and_monitor_cleanup() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + rates = [] + controller.set_sample_rate_changed_callback(lambda: rates.append(controller.active_sample_rate())) + assert controller.active_sample_rate() == 48000 + controller.update_processing_sample_rate(192000) + controller.update_processing_sample_rate(192000) + controller.update_processing_sample_rate(96000) + controller.stop_graph_rate_monitor() + assert rates == [192000, 96000, 48000] + controller.set_sample_rate_changed_callback(None) + controller.update_processing_sample_rate(44100) + assert rates == [192000, 96000, 48000] + + +def test_graph_rate_monitor_is_singleton_and_stopped() -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + calls = [] + + class Monitor: + def stop(self): + calls.append("stop") + + class Backend: + def create_graph_rate_monitor(self, sink_name, callback): + calls.append(sink_name) + callback(192000) + return Monitor() + + controller.output_backend = Backend() + controller.virtual_sink_name = "test_eq_sink" + controller.start_graph_rate_monitor() + controller.start_graph_rate_monitor() + assert controller.active_sample_rate() == 192000 + controller.stop_graph_rate_monitor() + controller.stop_graph_rate_monitor() + assert calls == ["test_eq_sink", "stop"] + assert controller.active_sample_rate() == 48000 + + class FakeOutputBackend: def __init__(self, sinks: list[pw_backend.PipeWireNode]) -> None: self.sinks = sinks @@ -1138,7 +1178,7 @@ def start_engine(*, on_ready=None, on_error=None) -> None: assert controller.running is True -def test_active_sample_rate_prefers_virtual_sink_then_output_sink() -> None: +def test_active_sample_rate_uses_fixed_dsp_clock_despite_node_properties() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.virtual_sink_name = "mini_eq_sink" controller.output_sink = "speakers" @@ -1149,10 +1189,10 @@ def test_active_sample_rate_prefers_virtual_sink_then_output_sink() -> None: ] ) - assert routing.SystemWideEqController.active_sample_rate(controller) == pytest.approx(96000.0) + assert routing.SystemWideEqController.active_sample_rate(controller) == pytest.approx(48000.0) -def test_active_sample_rate_uses_output_sink_when_virtual_sink_is_not_ready() -> None: +def test_active_sample_rate_uses_fixed_dsp_clock_before_sink_is_ready() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) controller.virtual_sink_name = "mini_eq_sink" controller.output_sink = "speakers" @@ -1162,7 +1202,7 @@ def test_active_sample_rate_uses_output_sink_when_virtual_sink_is_not_ready() -> ] ) - assert routing.SystemWideEqController.active_sample_rate(controller) == pytest.approx(44100.0) + assert routing.SystemWideEqController.active_sample_rate(controller) == pytest.approx(48000.0) def test_live_biquad_updates_use_active_sample_rate(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1562,6 +1602,77 @@ def watch_for_audio_sink(self, sink_name: str, callback, *, timeout_ms: int): assert controller.running is True +@pytest.mark.parametrize("replacement_fails", [False, True]) +def test_start_engine_preserves_completion_across_pending_type_changes(replacement_fails: bool) -> None: + controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) + callbacks = [] + unloaded = [] + ready = [] + errors = [] + monitored = [] + + class FakeWatch: + def cancel(self): + pass + + class FakeBackend: + def load_filter_chain_module(self, arguments): + return object() + + def unload_filter_chain_module(self, module): + unloaded.append(module) + + def sync(self): + pass + + def watch_for_audio_sink(self, name, callback, **kwargs): + callbacks.append(callback) + return FakeWatch() + + def set_node_params(self, node_id, controls): + pass + + controller.output_backend = FakeBackend() + controller.running = False + controller.routed = False + controller.stream_router = None + controller.filter_node_id = None + controller.virtual_sink_name = "mini_eq_sink" + controller.filter_output_name = "mini_eq_output" + controller.output_sink = "speakers" + controller.bands = [core.EqBand(core.FILTER_TYPES["Bell"], 1000)] + controller.preamp_db = 0.0 + controller.eq_enabled = True + controller.emit_status = lambda message: None + controller.start_filter_node_state_monitor = lambda: monitored.append(controller.filter_node_id) + controller.start_filter_control_param_monitor = lambda: None + controller.start_engine( + on_ready=lambda: ready.append(controller.filter_node_id), + on_error=lambda error: errors.append(str(error)), + ) + for index, name in enumerate(("Lo-pass", "Hi-pass")): + controller.bands[0].filter_type = core.FILTER_TYPES[name] + callbacks[index](make_node(42 + index, "mini_eq_sink")) + assert ready == [] + assert monitored == [] + assert controller.running is False + assert controller.filter_node_id is None + assert controller.engine_start_pending is True + + assert len(unloaded) == 2 + replacement = controller.engine_module + # Even a late timeout from an obsolete watch must not clear the new start. + callbacks[0](None) + assert controller.engine_module is replacement + assert controller.engine_start_pending is True + assert errors == [] + callbacks[2](None if replacement_fails else make_node(44, "mini_eq_sink")) + assert ready == ([] if replacement_fails else [44]) + assert monitored == ([] if replacement_fails else [44]) + assert errors == (["filter-chain did not create mini_eq_sink"] if replacement_fails else []) + assert controller.running is not replacement_fails + + def test_start_filter_control_param_monitor_subscribes_to_filter_props() -> None: controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController) calls: list[tuple[int, str]] = [] @@ -1733,6 +1844,7 @@ def set_node_params(self, _node_bound_id: int, _controls: dict[str, float]) -> N controller.running = True controller.filter_node_id = 42 controller.applying_filter_control_verification = True + controller.bands = [] routing.SystemWideEqController.set_filter_controls(controller, {"preamp_l:b0": 1.0}) routing.SystemWideEqController.handle_filter_control_param_changed(controller) diff --git a/tests/test_mini_eq_window_graph.py b/tests/test_mini_eq_window_graph.py index 6e7a18f..4f13aec 100644 --- a/tests/test_mini_eq_window_graph.py +++ b/tests/test_mini_eq_window_graph.py @@ -210,6 +210,25 @@ def band_point(self, index: int) -> tuple[float, float]: return x, y +def test_response_caches_follow_processing_rate() -> None: + band = core.EqBand(core.FILTER_TYPES["Bell"], 16000.0, gain_db=6.0) + window = GraphInteractionWindow([band]) + rate = 48000 + window.controller.active_sample_rate = lambda: rate + bounds = (600.0, 300.0, 30.0, 15.0, 15.0, 30.0) + total48 = window.total_response_points(*bounds) + selected48 = window.selected_response_points(*bounds, band) + assert window.total_response_points(*bounds) is total48 + assert window.selected_response_points(*bounds, band) is selected48 + rate = 192000 + total192 = window.total_response_points(*bounds) + selected192 = window.selected_response_points(*bounds, band) + assert total192 != total48 + assert selected192 != selected48 + assert window.total_response_points(*bounds) is total192 + assert window.selected_response_points(*bounds, band) is selected192 + + class FocusSummaryWindow(window_graph.MiniEqWindowGraphMixin): def __init__( self, diff --git a/tools/benchmark_fader_drag.py b/tools/benchmark_fader_drag.py index 9c7323d..7b5f58e 100644 --- a/tools/benchmark_fader_drag.py +++ b/tools/benchmark_fader_drag.py @@ -29,7 +29,7 @@ EQ_Q_MIN, ) from mini_eq.desktop_integration import APP_ID -from mini_eq.filter_chain import builtin_biquad_band_control_values +from mini_eq.filter_chain import native_biquad_band_control_values as builtin_biquad_band_control_values from mini_eq.pipewire_backend import build_props_controls_param from mini_eq.window import MiniEqWindow diff --git a/tools/check_flatpak_runtime.py b/tools/check_flatpak_runtime.py index 66ff4b6..71845b4 100644 --- a/tools/check_flatpak_runtime.py +++ b/tools/check_flatpak_runtime.py @@ -304,7 +304,9 @@ def start_smoke_stream(target: str | None, audio_file: Path) -> subprocess.Popen ), ] if target is not None: - command.extend(["--target", target]) + # Validate here as well as in argparse for programmatic callers. Keep + # the value attached to its option, even if validation changes later. + command.append(f"--target={pipewire_node_target(target)}") command.append(audio_file) print(f"$ {format_command(command)}", flush=True) return subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) diff --git a/tools/check_headless_pipewire_runtime.py b/tools/check_headless_pipewire_runtime.py index 26465d3..4dd409b 100755 --- a/tools/check_headless_pipewire_runtime.py +++ b/tools/check_headless_pipewire_runtime.py @@ -861,12 +861,60 @@ def run_controller_flow( raise RuntimeError(f"Mini EQ filter node entered PipeWire error state{detail}") controller.set_analyzer_enabled(False) + if getattr(controller, "_graph_rate_monitor", None) is not None: + expected_rate = int(os.environ.get("MINI_EQ_HEADLESS_PIPEWIRE_GRAPH_RATE", "48000")) + live.wait_for( + "controller processing clock with analyzer disabled", + lambda: controller.active_sample_rate() == expected_rate, + timeout_seconds, + ) + print(f"Controller processing clock: {controller.active_sample_rate():g} Hz", flush=True) print("## headless signal processing check with monitor off", flush=True) baseline_rms = capture_sink_monitor_rms( controller.output_sink, tmp_dir / "mini-eq-headless-baseline.raw", timeout_seconds, ) + # Exercise a frequency-selective live update, not only preamp gain: + # coefficients computed at the wrong rate still pass a preamp test. + controller.set_band_frequency(0, 440.0) + controller.set_band_q(0, 4.0) + controller.set_band_gain(0, -12.0) + bell_rms = capture_sink_monitor_rms( + controller.output_sink, + tmp_dir / "mini-eq-headless-bell.raw", + timeout_seconds, + ) + bell_db = 20.0 * math.log10(max(bell_rms, 1e-12) / max(baseline_rms, 1e-12)) + if not -13.0 <= bell_db <= -11.0: + raise RuntimeError(f"440 Hz bell response was {bell_db:.2f} dB; expected -12 dB") + print(f"Live 440 Hz bell response: {bell_db:.2f} dB", flush=True) + from mini_eq.core import FILTER_TYPES + + controller.set_band_type(0, FILTER_TYPES["Lo-pass"]) + controller.set_band_frequency(0, 100.0) + controller.set_band_q(0, 0.707) + dispatch_until("native low-pass topology ready", lambda: controller.running, timeout_seconds) + virtual_serial = wait_for_stream_routed_and_processing( + smoke_id, virtual_sink_name, filter_output_name, timeout_seconds, "after filter type change" + ) + lowpass_rms = capture_sink_monitor_rms(controller.output_sink, tmp_dir / "lowpass.raw", timeout_seconds) + lowpass_db = 20.0 * math.log10(max(lowpass_rms, 1e-12) / max(baseline_rms, 1e-12)) + if not -28.0 < lowpass_db < -24.0: + raise RuntimeError(f"Low-pass type change response incorrect: {lowpass_db:.2f} dB") + controller.set_band_mute(0, True) + bypass_rms = capture_sink_monitor_rms(controller.output_sink, tmp_dir / "lowpass-bypass.raw", timeout_seconds) + bypass_db = 20.0 * math.log10(max(bypass_rms, 1e-12) / max(baseline_rms, 1e-12)) + if abs(bypass_db) > 0.5: + raise RuntimeError(f"Low-pass bypass is not flat: {bypass_db:.2f} dB") + print(f"Native low-pass/type change: {lowpass_db:.2f} dB; bypass: {bypass_db:.2f} dB", flush=True) + controller.set_band_mute(0, False) + controller.set_band_gain(0, 0.0) + controller.set_band_type(0, FILTER_TYPES["Bell"]) + dispatch_until("native bell topology restored", lambda: controller.running, timeout_seconds) + virtual_serial = wait_for_stream_routed_and_processing( + smoke_id, virtual_sink_name, filter_output_name, timeout_seconds, "after restoring bell" + ) controller.preamp_db = SIGNAL_CHECK_PREAMP_DB controller.apply_state_to_engine() attenuated_rms = capture_sink_monitor_rms( @@ -1140,6 +1188,18 @@ def switch_to_primary_output() -> None: ) wait_for_processing_path_active(virtual_sink_name, filter_output_name, timeout_seconds) + monitor = controller.output_analyzer.stream + if hasattr(monitor, "get_graph_rate"): + expected_rate = int(os.environ.get("MINI_EQ_HEADLESS_PIPEWIRE_GRAPH_RATE", "48000")) + dispatch_until( + f"Mini EQ monitor graph clock {expected_rate} Hz", + lambda monitor=monitor, expected_rate=expected_rate: monitor.get_graph_rate() == expected_rate, + timeout_seconds, + ) + if monitor.get_rate() != 48000: + raise RuntimeError("Monitor negotiated format unexpectedly changed from 48000 Hz") + print(f"Mini EQ monitor: graph={monitor.get_graph_rate()} Hz, capture={monitor.get_rate()} Hz") + controller.set_analyzer_enabled(False) virtual_serial = dispatch_until( "synthetic stream stayed routed while monitor was disabled", @@ -1212,6 +1272,13 @@ def run_helper(_args: argparse.Namespace) -> int: runtime_dir.chmod(0o700) live.write_settings(config_dir) live.write_pipewire_config(config_dir) + graph_rate = int(os.environ["MINI_EQ_HEADLESS_PIPEWIRE_GRAPH_RATE"]) + rate_config = config_dir / "pipewire" / "pipewire.conf.d" / "20-mini-eq-test-rate.conf" + rate_config.write_text( + f"context.properties = {{ default.clock.rate = {graph_rate} " + f"default.clock.allowed-rates = [ {graph_rate} ] }}\n", + encoding="utf-8", + ) os.environ["XDG_RUNTIME_DIR"] = str(runtime_dir) os.environ["XDG_CONFIG_HOME"] = str(config_dir) @@ -1247,6 +1314,7 @@ def run_parent(args: argparse.Namespace) -> int: env["MINI_EQ_HEADLESS_PIPEWIRE_CYCLES"] = str(args.cycles) env["MINI_EQ_HEADLESS_PIPEWIRE_AUDIO_DURATION"] = str(args.audio_duration) env["MINI_EQ_HEADLESS_PIPEWIRE_IDLE_GAP"] = str(args.idle_gap) + env["MINI_EQ_HEADLESS_PIPEWIRE_GRAPH_RATE"] = str(args.graph_rate) env["PYTHONUNBUFFERED"] = "1" env.pop("DISPLAY", None) env.pop("WAYLAND_DISPLAY", None) @@ -1273,6 +1341,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--helper", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--timeout", type=float, default=35.0, help="Timeout for each PipeWire transition.") parser.add_argument("--cycles", type=int, default=3, help="Route and monitor toggle cycles to drive.") + parser.add_argument( + "--graph-rate", + type=int, + choices=(44100, 48000, 96000, 192000), + default=48000, + help="Sample rate of the isolated PipeWire graph.", + ) parser.add_argument( "--audio-duration", type=float, diff --git a/tools/demo_runtime.py b/tools/demo_runtime.py index 48a39ec..589315b 100644 --- a/tools/demo_runtime.py +++ b/tools/demo_runtime.py @@ -7,6 +7,7 @@ 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.pipewire_backend import PipeWireNode +from mini_eq.routing import OutputPresetTargetSnapshot, OutputPresetTargetTransition DEMO_PRESET_NAME = "Studio Reference" DEMO_OUTPUT_NAME = "studio-monitor" @@ -33,6 +34,14 @@ def demo_analyzer_loudness() -> AnalyzerLoudnessSnapshot: class DemoController: + def output_preset_target_transition(self, *, consume: bool = True) -> OutputPresetTargetTransition: + previous = getattr(self, "_observed_output_preset_target_snapshot", None) + current = OutputPresetTargetSnapshot(self.output_sink, self.output_sink, None) + changed = previous is not None and previous.identity != current.identity + if consume or previous is None: + self._observed_output_preset_target_snapshot = current + return OutputPresetTargetTransition(previous, current, changed) + def __init__(self) -> None: self.output_sink = DEMO_OUTPUT_NAME self.virtual_sink_name = DEMO_VIRTUAL_SINK_LABEL diff --git a/tools/gnome-shell-extension/fake_mini_eq_control.py b/tools/gnome-shell-extension/fake_mini_eq_control.py index 5c69a51..267a3c2 100755 --- a/tools/gnome-shell-extension/fake_mini_eq_control.py +++ b/tools/gnome-shell-extension/fake_mini_eq_control.py @@ -21,6 +21,17 @@ ANALYZER_DB_FLOOR = -100.0 API_VERSION = 1 APP_VERSION = "dev" +DEFAULT_PRESETS = ("Studio Reference", "Flat", "Voice Focus") +DEMO_PRESET_SUFFIXES = ( + "Studio Reference", + "Bright Headphones", + "Late Night Speakers", + "Voice Focus", + "Bass Trim", + "Travel Earbuds", + "Living Room", + "Desk Monitors", +) CAPABILITIES = ( "present-window", "quit", @@ -93,13 +104,26 @@ def display_level(level: float) -> float: return max(0.0, min(1.0, deflection / 115.0)) +def demo_presets(count: int) -> list[str]: + if count <= 0: + return [] + if count <= len(DEFAULT_PRESETS): + return list(DEFAULT_PRESETS[:count]) + + presets = list(DEFAULT_PRESETS) + for index in range(len(DEFAULT_PRESETS) + 1, count + 1): + suffix = DEMO_PRESET_SUFFIXES[(index - 1) % len(DEMO_PRESET_SUFFIXES)] + presets.append(f"Preset {index:02d} - {suffix}") + return presets + + class FakeMiniEqControl: - def __init__(self, *, analyzer_enabled: bool = True) -> None: + def __init__(self, *, analyzer_enabled: bool = True, preset_count: int = len(DEFAULT_PRESETS)) -> None: self.eq_enabled = True self.routed = True - self.preset_name = "Studio Reference" self.output_preset_name = "Demo Output Link" - self.presets = ["Studio Reference", "Flat", "Voice Focus"] + self.presets = demo_presets(preset_count) + self.preset_name = self.presets[0] if self.presets else "" self.analyzer_enabled = analyzer_enabled self.analyzer_levels = [0.0] * 10 self.animation_step = 0 @@ -264,9 +288,17 @@ def parse_args() -> argparse.Namespace: action="store_false", help="report analyzer monitoring as disabled", ) + parser.add_argument( + "--preset-count", + type=int, + default=len(DEFAULT_PRESETS), + help="number of demo presets exposed by ListPresets", + ) return parser.parse_args() if __name__ == "__main__": args = parse_args() - FakeMiniEqControl(analyzer_enabled=args.analyzer_enabled).run() + if args.preset_count < 0: + raise SystemExit("--preset-count must be greater than or equal to 0") + FakeMiniEqControl(analyzer_enabled=args.analyzer_enabled, preset_count=args.preset_count).run() diff --git a/tools/run_gnome_extension_dev_shell.sh b/tools/run_gnome_extension_dev_shell.sh index 6bf8833..c1cc7e3 100755 --- a/tools/run_gnome_extension_dev_shell.sh +++ b/tools/run_gnome_extension_dev_shell.sh @@ -12,13 +12,15 @@ dev_config_home="$dev_home/config" dev_cache_home="$dev_home/cache" bundle="$dev_home/$uuid.shell-extension.zip" mode="fake" +fake_preset_count="" usage() { cat >&2 < 0)); do --fake-control-monitor-off) mode="fake-monitor-off" ;; + --fake-preset-count) + if (($# < 2)); then + usage + exit 2 + fi + fake_preset_count="$2" + shift + ;; --no-fake-control) mode="no-fake" ;; @@ -157,18 +167,22 @@ run_in_dev_bus() { fake_control="$1" fake_mode="$2" - shift 2 + fake_preset_count="$3" + shift 3 fake_args=() if [[ "$fake_mode" == "fake-monitor-off" ]]; then fake_args+=(--monitor-off) fi + if [[ -n "$fake_preset_count" ]]; then + fake_args+=(--preset-count "$fake_preset_count") + fi "$fake_control" "${fake_args[@]}" & fake_pid=$! sleep 0.5 "$@" - ' bash "$fake_control" "$mode" "${shell_command[@]}" + ' bash "$fake_control" "$mode" "$fake_preset_count" "${shell_command[@]}" } if gnome-shell --help 2>&1 | grep -q -- '--devkit'; then