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.
+

## 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