diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8185f4f..73339d9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -240,11 +240,11 @@ jobs:
run: |
python3 -m venv /tmp/mini-eq-pwg-build
/tmp/mini-eq-pwg-build/bin/python -m pip install --upgrade pip
- /tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.4,<0.4' -w /tmp/mini-eq-wheelhouse
+ /tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.5,<0.4' -w /tmp/mini-eq-wheelhouse
python3 -m venv --system-site-packages .venv
.venv/bin/python -m pip install --upgrade pip
- .venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4'
+ .venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.5,<0.4'
.venv/bin/python -m pip install -e '.[dev]'
- name: Lint
@@ -272,7 +272,7 @@ jobs:
run: |
python3 -m venv --system-site-packages /tmp/mini-eq-wheel-test
/tmp/mini-eq-wheel-test/bin/python -m pip install --upgrade pip
- /tmp/mini-eq-wheel-test/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4'
+ /tmp/mini-eq-wheel-test/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.5,<0.4'
/tmp/mini-eq-wheel-test/bin/python -m pip install dist/mini_eq-*.whl
/tmp/mini-eq-wheel-test/bin/mini-eq --help
@@ -318,7 +318,7 @@ jobs:
- name: Check pipewire-gobject GI compatibility
run: |
flatpak run --filesystem="$PWD":ro --command=python3 io.github.bhack.mini-eq \
- "$PWD/tools/check_pipewire_gobject.py" --expect-version 0.3.4
+ "$PWD/tools/check_pipewire_gobject.py" --expect-version 0.3.5
- name: Smoke-test Flatpak runtime modules
run: |
diff --git a/AGENTS.md b/AGENTS.md
index 3ccbb4c..d428702 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -103,8 +103,11 @@ modules from a system-site build venv.
- Prefer existing patterns and small, targeted patches.
- Do not move logic between the large modules just to tidy them; split modules
only when the user asked for that refactor or the change needs it.
-- Keep the pipewire-gobject API boundary small and app-facing. WirePlumber stays
- the host session manager, not a bundled GI dependency.
+- Keep the pipewire-gobject API boundary small, general-purpose, and
+ app-facing. Mini EQ may validate new pipewire-gobject API in a real GTK app,
+ but do not add Mini EQ-shaped concepts, preset/filter-chain policy, or
+ hardware-selection policy to pipewire-gobject. WirePlumber stays the host
+ session manager, not a bundled GI dependency.
- Treat the Mini EQ D-Bus control interface as a project-internal app/Shell
extension contract with version-skew tolerance. Keep `api_version = 1`
additive only: add state fields, methods, and capabilities when needed, but do
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 952baf6..645cb4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,14 @@
# Changelog
-## 0.6.0 - 2026-05-09
+## 0.7.0 - 2026-05-09
- Replace the direct WirePlumber Python integration with pipewire-gobject for
app-facing PipeWire registry, metadata, routing, and monitor-capture access.
-- Bundle pipewire-gobject 0.3.4 in Flatpak and require
- `pipewire-gobject>=0.3.4,<0.4` for PyPI installs.
+- Use pipewire-gobject device route params so auto preset links follow the
+ detected output port when available while preserving output-node links as a
+ fallback.
+- Bundle pipewire-gobject 0.3.5 in Flatpak and require
+ `pipewire-gobject>=0.3.5,<0.4` for PyPI installs.
- Keep system-wide EQ, monitor capture, output changes, and shutdown behavior
covered by a live nested-GNOME/AT-SPI runtime smoke test.
- Remove the old WirePlumber 0.4 compatibility build path and tighten release
diff --git a/README.md b/README.md
index fa94ab6..18dc59d 100644
--- a/README.md
+++ b/README.md
@@ -23,12 +23,12 @@ also show live LUFS loudness.
- PipeWire filter-chain DSP using builtin biquad filters.
- Optional spectrum analyzer and LUFS loudness readout through a PipeWire monitor
capture stream.
-- Per-output preset links for automatically using different saved presets with
- headphones, speakers, HDMI, and other outputs.
+- Auto preset links can follow the detected PipeWire port when available and
+ fall back to the selected EQ output when a port is not reported.
- Optional background mode keeps the EQ active after closing the window, with a
separate Start at Login preference and optional active-at-login routing.
- Optional GNOME Shell extension for quick panel access to routing, EQ,
- analyzer status, presets, and output preset links.
+ analyzer status, presets, and auto preset links.
- Equalizer APO preset import from the UI or `--import-apo`, including
compatible presets exported by [AutoEq](https://autoeq.app/).
@@ -134,11 +134,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.4,<0.4' -w /tmp/mini-eq-wheelhouse
+/tmp/mini-eq-pwg-build/bin/python -m pip wheel 'pipewire-gobject>=0.3.5,<0.4' -w /tmp/mini-eq-wheelhouse
python3 -m venv --system-site-packages ~/.local/share/mini-eq/venv
~/.local/share/mini-eq/venv/bin/python -m pip install --upgrade pip
-~/.local/share/mini-eq/venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.4,<0.4'
+~/.local/share/mini-eq/venv/bin/python -m pip install --no-index --find-links /tmp/mini-eq-wheelhouse 'pipewire-gobject>=0.3.5,<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
@@ -168,7 +168,7 @@ mini-eq --install-desktop
## GNOME Shell Extension
Mini EQ also has an optional GNOME Shell extension for quick panel access to
-routing, EQ, analyzer status, presets, and output preset links.
+routing, EQ, analyzer status, presets, and auto preset links.
Install it from GNOME Shell Extensions:
https://extensions.gnome.org/extension/9803/mini-eq-controls/
diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml
index b32d6c5..53416f5 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.6.0/docs/screenshots/mini-eq.png
+ https://raw.githubusercontent.com/bhack/mini-eq/v0.7.0/docs/screenshots/mini-eq.png
Adjust sound output with equalizer controls
- https://raw.githubusercontent.com/bhack/mini-eq/v0.6.0/docs/screenshots/mini-eq-dark.png
+ https://raw.githubusercontent.com/bhack/mini-eq/v0.7.0/docs/screenshots/mini-eq-dark.png
Use the equalizer with dark style
@@ -45,11 +45,12 @@
https://github.com/bhack/mini-eq/issues
https://github.com/bhack/mini-eq
-
+
- Use pipewire-gobject for PipeWire registry, metadata, routing, and monitor capture.
- - Bundle pipewire-gobject 0.3.4 in Flatpak and remove the old WirePlumber 0.4 compatibility build path.
+ - Add auto preset links that follow the detected output port when available.
+ - Bundle pipewire-gobject 0.3.5 in Flatpak and remove the old WirePlumber 0.4 compatibility build path.
- Improve live runtime validation for routing, monitor capture, output changes, and shutdown behavior.
diff --git a/docs/screenshots/mini-eq-dark.png b/docs/screenshots/mini-eq-dark.png
index 445a57d..f1cacd3 100644
Binary files a/docs/screenshots/mini-eq-dark.png and b/docs/screenshots/mini-eq-dark.png differ
diff --git a/docs/screenshots/mini-eq.png b/docs/screenshots/mini-eq.png
index b99270f..080eb0b 100644
Binary files a/docs/screenshots/mini-eq.png and b/docs/screenshots/mini-eq.png differ
diff --git a/docs/social-preview.png b/docs/social-preview.png
index 4bf8967..4cc1839 100644
Binary files a/docs/social-preview.png and b/docs/social-preview.png differ
diff --git a/extensions/gnome-shell/README.md b/extensions/gnome-shell/README.md
index 4704a00..df33013 100644
--- a/extensions/gnome-shell/README.md
+++ b/extensions/gnome-shell/README.md
@@ -17,6 +17,7 @@ Current scope:
- Focus or present the Mini EQ app from the panel menu.
- Control Mini EQ system-wide routing and equalized/original audio state.
- List and load saved Mini EQ presets over the Mini EQ D-Bus control API.
+- Show the current auto preset link exposed by Mini EQ.
Install for local testing:
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 be51398..8223352 100644
--- a/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js
+++ b/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js
@@ -90,7 +90,7 @@ class MiniEqIndicator extends PanelMenu.Button {
this._statusItem.setSensitive(false);
this.menu.addMenuItem(this._statusItem);
- this._outputPresetItem = new PopupMenu.PopupMenuItem(_('Output preset: None'));
+ this._outputPresetItem = new PopupMenu.PopupMenuItem(_('Auto preset: None'));
this._outputPresetItem.setSensitive(false);
this.menu.addMenuItem(this._outputPresetItem);
@@ -387,7 +387,7 @@ class MiniEqIndicator extends PanelMenu.Button {
this._presetsItem.setSensitive(false);
this._presetsItem.label.text = _('Presets');
this._statusItem.label.text = _('Mini EQ is not running');
- this._outputPresetItem.label.text = _('Output preset: None');
+ this._outputPresetItem.label.text = _('Auto preset: None');
this._quitItem.visible = false;
this._setAnalyzerLevels([]);
this._setPresets([]);
@@ -405,8 +405,8 @@ class MiniEqIndicator extends PanelMenu.Button {
_outputPresetText(running, presetName) {
if (!running || !presetName)
- return _('Output preset: None');
- return _('Output preset: %s').format(presetName);
+ return _('Auto preset: None');
+ return _('Auto preset: %s').format(presetName);
}
_applyAnalyzerLevels(levels) {
diff --git a/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json b/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json
index 6bf19a7..0cbbfd4 100644
--- a/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json
+++ b/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json
@@ -1,7 +1,7 @@
{
"uuid": "mini-eq@bhack.github.io",
"name": "Mini EQ Controls",
- "description": "Control Mini EQ from the panel: open the app, toggle system-wide routing and EQ, view analyzer activity, switch saved presets, and see output preset links.",
+ "description": "Control Mini EQ from the panel: open the app, toggle system-wide routing and EQ, view analyzer activity, switch saved presets, and see auto preset links.",
"shell-version": ["50"],
"url": "https://github.com/bhack/mini-eq"
}
diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml
index 4b8b22b..7076270 100644
--- a/io.github.bhack.mini-eq.yaml
+++ b/io.github.bhack.mini-eq.yaml
@@ -124,8 +124,8 @@ modules:
sources:
- type: git
url: https://github.com/bhack/pipewire-gobject.git
- tag: 0.3.4
- commit: 82658f10338c2e9530ae575db30f15e709626cdc
+ tag: 0.3.5
+ commit: b570bc4ff0a53223416fff9a4fc05d367273789d
- python3-dependencies.yaml
diff --git a/pyproject.toml b/pyproject.toml
index c30219c..eb5ad0a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mini-eq"
-version = "0.6.0"
+version = "0.7.0"
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.4,<0.4"]
+dependencies = ["numpy>=1.26", "pipewire-gobject>=0.3.5,<0.4"]
[project.urls]
Homepage = "https://github.com/bhack/mini-eq"
diff --git a/src/mini_eq/app.py b/src/mini_eq/app.py
index 6e3c722..ac4d407 100644
--- a/src/mini_eq/app.py
+++ b/src/mini_eq/app.py
@@ -28,6 +28,7 @@
from .instance import MiniEqAlreadyRunningError, MiniEqInstanceGuard
from .routing import SystemWideEqController
from .window import MiniEqWindow
+from .window_presets import imported_apo_curve_label
class MiniEqApplication(Adw.Application):
@@ -91,6 +92,7 @@ def ensure_window(self, *, present: bool) -> None:
return
controller: SystemWideEqController | None = None
+ initial_curve_label: str | None = None
try:
controller = SystemWideEqController(self.args.output_sink)
@@ -98,13 +100,14 @@ def ensure_window(self, *, present: bool) -> None:
if self.args.import_apo:
controller.import_apo_preset(self.args.import_apo)
+ initial_curve_label = imported_apo_curve_label(self.args.import_apo)
except Exception as exc:
if controller is not None:
controller.shutdown()
raise SystemExit(str(exc)) from exc
self.controller = controller
- self.window = MiniEqWindow(self, self.controller, self.args.auto_route)
+ self.window = MiniEqWindow(self, self.controller, self.args.auto_route, initial_curve_label=initial_curve_label)
self.window.set_icon_name(APP_ICON_NAME)
self.window.present_after_setup = present
self.window.set_visible(present)
diff --git a/src/mini_eq/core.py b/src/mini_eq/core.py
index 7f88fd0..7ba924c 100644
--- a/src/mini_eq/core.py
+++ b/src/mini_eq/core.py
@@ -5,6 +5,7 @@
import math
import os
import re
+from collections.abc import Iterable
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
@@ -351,6 +352,23 @@ def normalize_default_preset_name(preset_name: object) -> str | None:
return default_preset or None
+def normalize_output_preset_key_candidates(output_keys: str | Iterable[str | None] | None) -> list[str]:
+ if output_keys is None or isinstance(output_keys, str):
+ raw_keys: Iterable[str | None] = (output_keys,)
+ else:
+ raw_keys = output_keys
+
+ normalized: list[str] = []
+ seen: set[str] = set()
+ for raw_key in raw_keys:
+ output_key = str(raw_key or "").strip()
+ if output_key and output_key not in seen:
+ normalized.append(output_key)
+ seen.add(output_key)
+
+ return normalized
+
+
def load_output_preset_config() -> tuple[dict[str, str], str | None]:
links_path = output_preset_links_path()
if not links_path.exists():
@@ -399,13 +417,23 @@ def write_output_preset_links(links: dict[str, str]) -> None:
write_output_preset_config(links)
-def get_output_preset_link(sink_name: str | None) -> str | None:
- output_key = str(sink_name or "").strip()
- if not output_key:
+def get_output_preset_link_match(output_keys: str | Iterable[str | None] | None) -> tuple[str, str] | None:
+ candidates = normalize_output_preset_key_candidates(output_keys)
+ if not candidates:
return None
links, _default_preset = load_output_preset_config()
- return links.get(output_key)
+ for output_key in candidates:
+ preset_name = links.get(output_key)
+ if preset_name:
+ return output_key, preset_name
+
+ return None
+
+
+def get_output_preset_link(output_keys: str | Iterable[str | None] | None) -> str | None:
+ match = get_output_preset_link_match(output_keys)
+ return match[1] if match is not None else None
def get_default_preset_name() -> str | None:
@@ -428,13 +456,17 @@ def set_output_preset_link(sink_name: str, preset_name: str) -> str:
return linked_preset
-def clear_output_preset_link(sink_name: str | None) -> str | None:
- output_key = str(sink_name or "").strip()
- if not output_key:
+def clear_output_preset_link(output_keys: str | Iterable[str | None] | None) -> str | None:
+ candidates = normalize_output_preset_key_candidates(output_keys)
+ if not candidates:
return None
links, default_preset = load_output_preset_config()
- removed = links.pop(output_key, None)
+ removed = None
+ for output_key in candidates:
+ candidate = links.pop(output_key, None)
+ if removed is None and candidate:
+ removed = candidate
write_output_preset_config(links, default_preset)
return removed
diff --git a/src/mini_eq/deps.py b/src/mini_eq/deps.py
index 6ec15c8..f4316a9 100644
--- a/src/mini_eq/deps.py
+++ b/src/mini_eq/deps.py
@@ -12,11 +12,15 @@
Status = Literal["ok", "missing", "warning"]
-PWG_REQUIRED_VERSION = "0.3.4"
-PWG_REQUIRED_VERSION_PARTS = (0, 3, 4)
+PWG_REQUIRED_VERSION = "0.3.5"
+PWG_REQUIRED_VERSION_PARTS = (0, 3, 5)
PWG_REQUIRED_SYMBOLS = (
"Core.set_pipewire_property",
+ "Device.enum_all_params",
+ "Device.enum_params",
+ "Device.new",
"Param.new_props_controls",
+ "RouteInfo.new_from_param",
"Stream.set_pipewire_property",
)
PYGOBJECT_HINT = "Ubuntu/Debian: python3-gi; Fedora: python3-gobject; Arch: python-gobject"
diff --git a/src/mini_eq/pipewire_backend.py b/src/mini_eq/pipewire_backend.py
index de96bcb..365b43b 100644
--- a/src/mini_eq/pipewire_backend.py
+++ b/src/mini_eq/pipewire_backend.py
@@ -5,6 +5,8 @@
from dataclasses import dataclass, field
from typing import Any
+from .pipewire_routes import PipeWireRouteMixin
+
DEFAULT_METADATA_NAME = "default"
DEFAULT_AUDIO_SINK_KEY = "default.audio.sink"
DEFAULT_CONFIGURED_AUDIO_SINK_KEY = "default.configured.audio.sink"
@@ -12,6 +14,7 @@
TARGET_NODE_KEY = "target.node"
SPA_ID_TYPE = "Spa:Id"
PIPEWIRE_NODE_INTERFACE = "PipeWire:Interface:Node"
+PIPEWIRE_DEVICE_INTERFACE = "PipeWire:Interface:Device"
STREAM_OUTPUT_AUDIO = "Stream/Output/Audio"
AUDIO_SINK = "Audio/Sink"
FILTER_CHAIN_MODULE_NAME = "libpipewire-module-filter-chain"
@@ -30,6 +33,8 @@ class PipeWireNode:
node_description: str | None
application_name: str | None
node_dont_move: bool
+ device_id: int = 0
+ card_profile_device: int = 0
properties: dict[str, str] = field(default_factory=dict)
@property
@@ -124,7 +129,7 @@ def node_sample_rate(node: PipeWireNode | None) -> float:
return float(rate) if rate > 0 else 0.0
-class PipeWireBackend:
+class PipeWireBackend(PipeWireRouteMixin):
def __init__(self, timeout_ms: int = 2000) -> None:
self.timeout_ms = timeout_ms
self._connected = False
@@ -136,9 +141,12 @@ def __init__(self, timeout_ms: int = 2000) -> None:
self._metadata: Any = None
self._metadata_signal_objects: dict[int, Any] = {}
self._node_signal_objects: dict[int, Any] = {}
+ self._device_signal_objects: dict[int, Any] = {}
self._node_proxies: dict[int, Any] = {}
+ self._device_proxies: dict[int, Any] = {}
self._loaded_modules: list[Any] = []
self._cached_defaults = PipeWireDefaults(None, None)
+ self._device_route_refreshing_bound_ids: set[int] = set()
def __enter__(self) -> PipeWireBackend:
self.connect()
@@ -187,6 +195,9 @@ def close(self) -> None:
pass
self._node_signal_objects.clear()
+ for handler_id in list(self._device_signal_objects):
+ self.disconnect_device_handler(handler_id)
+
for node in list(self._node_proxies.values()):
try:
node.stop()
@@ -194,6 +205,13 @@ def close(self) -> None:
pass
self._node_proxies.clear()
+ for device in list(self._device_proxies.values()):
+ try:
+ device.stop()
+ except Exception:
+ pass
+ self._device_proxies.clear()
+
for module in list(self._loaded_modules):
try:
module.unload()
@@ -288,6 +306,61 @@ def disconnect_metadata_handler(self, handler_id: int) -> None:
except Exception:
pass
+ def connect_device_route_changed(self, device_bound_id: int, callback) -> int:
+ self._ensure_connected()
+
+ if not self._has_device_route_subscription_api():
+ return 0
+
+ device = self._device_proxy_by_bound_id(device_bound_id)
+ if device is None:
+ return 0
+
+ route_param_id = self._device_route_param_id(device)
+ if route_param_id is None:
+ return 0
+
+ def on_device_param(_device, param) -> None:
+ try:
+ param_id = int(param.get_id())
+ except Exception:
+ return
+
+ if param_id != route_param_id or int(device_bound_id) in self._device_route_refreshing_bound_ids:
+ return
+
+ callback()
+
+ handler_id = self._GObject.Object.connect(device, "param", on_device_param)
+ self._device_signal_objects[handler_id] = device
+
+ try:
+ device.subscribe_params(self._GLib.Variant("au", [route_param_id]))
+ except Exception:
+ self.disconnect_device_handler(handler_id)
+ return 0
+
+ return handler_id
+
+ def disconnect_device_handler(self, handler_id: int) -> None:
+ if handler_id <= 0:
+ return
+
+ device = self._device_signal_objects.pop(handler_id, None)
+ if device is None:
+ return
+
+ try:
+ if self._GLib is not None and hasattr(device, "subscribe_params"):
+ device.subscribe_params(self._GLib.Variant("au", []))
+ except Exception:
+ pass
+
+ try:
+ device.disconnect(handler_id)
+ except Exception:
+ pass
+
def sync(self) -> None:
self._ensure_connected()
self._sync_core()
@@ -471,6 +544,10 @@ def _default_metadata(self):
def _node_from_global(self, global_) -> PipeWireNode:
properties = self._properties_dict(global_)
+ device_id = parse_positive_int(self._pw_property(global_, "device.id", properties))
+ if device_id > 0:
+ properties = self._properties_with_device_labels(properties, device_id)
+
return PipeWireNode(
bound_id=int(global_.get_id()),
object_serial=self._pw_property(global_, "object.serial", properties),
@@ -479,6 +556,8 @@ def _node_from_global(self, global_) -> PipeWireNode:
node_description=self._pw_property(global_, "node.description", properties),
application_name=self._pw_property(global_, "application.name", properties),
node_dont_move=parse_bool_property(self._pw_property(global_, "node.dont-move", properties)),
+ device_id=device_id,
+ card_profile_device=parse_positive_int(self._pw_property(global_, "card.profile.device", properties)),
properties=properties,
)
@@ -500,6 +579,24 @@ def _node_proxy_by_bound_id(self, bound_id: int):
self._node_proxies[int(bound_id)] = node
return node
+ def _device_proxy_by_bound_id(self, bound_id: int):
+ global_ = self._registry.lookup_global(int(bound_id))
+ if global_ is None or not global_.is_device():
+ return None
+
+ device = self._device_proxies.get(int(bound_id))
+ if device is not None and device.get_running():
+ return device
+
+ device = self._Pwg.Device.new(self._core, global_)
+ if device is None:
+ return None
+ if not device.start():
+ raise PipeWireBackendError(f"failed to bind device: {bound_id}")
+
+ self._device_proxies[int(bound_id)] = device
+ return device
+
def _pw_property(self, global_, key: str, properties: dict[str, str] | None = None) -> str | None:
if properties is not None and key in properties:
return properties[key]
diff --git a/src/mini_eq/pipewire_routes.py b/src/mini_eq/pipewire_routes.py
new file mode 100644
index 0000000..1ab88c8
--- /dev/null
+++ b/src/mini_eq/pipewire_routes.py
@@ -0,0 +1,297 @@
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from urllib.parse import quote
+
+OUTPUT_PRESET_ROUTE_KEY_PREFIX = "pipewire-route:v1:"
+OUTPUT_ROUTE_DIRECTION = "output"
+DEVICE_ROUTE_PARAM_NAME = "Route"
+DEVICE_LABEL_PROPERTY_KEYS = ("device.description", "device.nick", "device.name")
+
+
+@dataclass(frozen=True)
+class PipeWireOutputRoute:
+ device_bound_id: int
+ device_name: str | None
+ index: int
+ route_device: int
+ profile: int
+ priority: int
+ direction: str | None
+ name: str | None
+ description: str | None
+ availability: str | None
+ info: dict[str, str] = field(default_factory=dict)
+
+ @property
+ def output_preset_key(self) -> str | None:
+ return build_output_route_preset_key(self.device_name, self.name, self.route_device)
+
+
+@dataclass(frozen=True)
+class PipeWireOutputPresetTarget:
+ output_key: str | None
+ route: PipeWireOutputRoute | None
+ keys: tuple[str, ...]
+
+ @property
+ def link_key(self) -> str:
+ return next(iter(self.keys), self.output_key or "")
+
+ @property
+ def has_route_key(self) -> bool:
+ route_key = self.route.output_preset_key if self.route is not None else None
+ return route_key is not None and route_key in self.keys
+
+
+def build_output_route_preset_key(device_name: str | None, route_name: str | None, route_device: int) -> str | None:
+ device = str(device_name or "").strip()
+ route = str(route_name or "").strip()
+ if not device or not route:
+ return None
+
+ encoded_device = quote(device, safe="")
+ encoded_route = quote(route, safe="")
+ return f"{OUTPUT_PRESET_ROUTE_KEY_PREFIX}device={encoded_device};route={encoded_route};route-device={int(route_device)}"
+
+
+class PipeWireRouteMixin:
+ def output_preset_keys_for_sink_name(self, sink_name: str | None) -> tuple[str, ...]:
+ return self.output_preset_target_for_sink_name(sink_name).keys
+
+ def output_preset_target_for_sink_name(self, sink_name: str | None) -> PipeWireOutputPresetTarget:
+ node_name = str(sink_name or "").strip()
+ if not node_name:
+ return PipeWireOutputPresetTarget(None, None, ())
+
+ keys: list[str] = []
+ sink = self.audio_sink_by_name(node_name)
+ route = self.output_route_for_sink(sink)
+ route_key = route.output_preset_key if route is not None else None
+ if route_key:
+ keys.append(route_key)
+ keys.append(node_name)
+
+ return PipeWireOutputPresetTarget(node_name, route, tuple(dict.fromkeys(keys)))
+
+ def output_route_for_sink(self, sink) -> PipeWireOutputRoute | None:
+ if sink is None or sink.device_id <= 0 or not self._has_device_route_api():
+ return None
+
+ device = self._device_proxy_by_bound_id(sink.device_id)
+ if device is None:
+ return None
+
+ routes = self._enumerate_device_routes(device, sink.device_id)
+ output_routes = [
+ route
+ for route in routes
+ if str(route.direction or "").casefold() == OUTPUT_ROUTE_DIRECTION
+ and (route.availability or "unknown").casefold() != "no"
+ ]
+ if not output_routes:
+ return None
+
+ if sink.card_profile_device > 0:
+ for route in output_routes:
+ if route.route_device == sink.card_profile_device:
+ return route
+
+ if len(output_routes) == 1:
+ return output_routes[0]
+
+ return None
+
+ def _has_device_route_api(self) -> bool:
+ return (
+ self._Pwg is not None
+ and hasattr(self._Pwg, "Device")
+ and hasattr(self._Pwg, "RouteInfo")
+ and hasattr(self._Pwg.Device, "enum_params")
+ and hasattr(self._Pwg.Device, "new")
+ and hasattr(self._Pwg.RouteInfo, "new_from_param")
+ )
+
+ def _has_device_route_subscription_api(self) -> bool:
+ return (
+ self._has_device_route_api()
+ and self._GLib is not None
+ and self._GObject is not None
+ and hasattr(self._Pwg.Device, "subscribe_params")
+ )
+
+ def _device_name_by_bound_id(self, bound_id: int) -> str | None:
+ return self._device_properties_by_bound_id(bound_id).get("device.name")
+
+ def _properties_with_device_labels(self, node_properties: dict[str, str], device_bound_id: int) -> dict[str, str]:
+ device_properties = self._device_properties_by_bound_id(device_bound_id)
+ if not device_properties:
+ return node_properties
+
+ merged = dict(node_properties)
+ for key in DEVICE_LABEL_PROPERTY_KEYS:
+ value = device_properties.get(key)
+ if value and not merged.get(key):
+ merged[key] = value
+
+ return merged
+
+ def _device_properties_by_bound_id(self, bound_id: int) -> dict[str, str]:
+ if self._registry is None:
+ return {}
+
+ try:
+ global_ = self._registry.lookup_global(int(bound_id))
+ except Exception:
+ return {}
+
+ try:
+ if global_ is None or not global_.is_device():
+ return {}
+ except Exception:
+ return {}
+
+ return self._properties_dict(global_)
+
+ def _enumerate_device_routes(self, device, device_bound_id: int) -> list[PipeWireOutputRoute]:
+ route_param_id = self._device_route_param_id(device)
+ if route_param_id is None:
+ return []
+
+ bound_id = int(device_bound_id)
+ self._device_route_refreshing_bound_ids.add(bound_id)
+ try:
+ seq = int(device.enum_params(route_param_id, 0, 0))
+ if seq < 0:
+ return []
+
+ self._wait_for_param_sequence(lambda: device.get_params(), seq)
+ device_name = self._device_name_by_bound_id(device_bound_id)
+ routes: list[PipeWireOutputRoute] = []
+ for param in self._iterate_model(device.get_params()):
+ try:
+ param_name = param.dup_name()
+ except Exception:
+ param_name = None
+ if param_name != DEVICE_ROUTE_PARAM_NAME:
+ continue
+
+ try:
+ route_info = self._Pwg.RouteInfo.new_from_param(param)
+ except Exception:
+ continue
+
+ if route_info is None:
+ continue
+
+ routes.append(self._output_route_from_info(route_info, device_bound_id, device_name))
+
+ return routes
+ except Exception:
+ return []
+ finally:
+ self._device_route_refreshing_bound_ids.discard(bound_id)
+
+ def _device_route_param_id(self, device) -> int | None:
+ route_param_id = self._device_param_id_by_name(device, DEVICE_ROUTE_PARAM_NAME)
+ if route_param_id is None:
+ self._wait_for_device_param_info(device, DEVICE_ROUTE_PARAM_NAME)
+ route_param_id = self._device_param_id_by_name(device, DEVICE_ROUTE_PARAM_NAME)
+ return route_param_id
+
+ def _device_param_id_by_name(self, device, name: str) -> int | None:
+ for param_info in self._iterate_model(device.get_param_infos()):
+ try:
+ param_name = param_info.dup_name()
+ param_id = int(param_info.get_id())
+ except Exception:
+ continue
+
+ if param_name == name:
+ return param_id
+
+ return None
+
+ def _wait_for_device_param_info(self, device, name: str) -> None:
+ if self._GLib is None:
+ return
+
+ deadline = time.monotonic() + min(self.timeout_ms / 1000.0, 0.2)
+ context = self._GLib.MainContext.default()
+ while time.monotonic() < deadline:
+ while context.pending():
+ context.iteration(False)
+
+ if self._device_param_id_by_name(device, name) is not None:
+ return
+
+ time.sleep(0.005)
+
+ def _output_route_from_info(
+ self,
+ route_info,
+ device_bound_id: int,
+ device_name: str | None,
+ ) -> PipeWireOutputRoute:
+ return PipeWireOutputRoute(
+ device_bound_id=device_bound_id,
+ device_name=device_name,
+ index=int(route_info.get_index()),
+ route_device=int(route_info.get_device()),
+ profile=int(route_info.get_profile()),
+ priority=int(route_info.get_priority()),
+ direction=route_info.dup_direction(),
+ name=route_info.dup_name(),
+ description=route_info.dup_description(),
+ availability=route_info.dup_availability(),
+ info=self._variant_to_string_dict(route_info.get_info()),
+ )
+
+ def _wait_for_param_sequence(self, model_factory, seq: int) -> None:
+ if self._GLib is None:
+ return
+
+ deadline = time.monotonic() + min(self.timeout_ms / 1000.0, 0.2)
+ context = self._GLib.MainContext.default()
+ while time.monotonic() < deadline:
+ while context.pending():
+ context.iteration(False)
+
+ if self._model_has_param_sequence(model_factory(), seq):
+ return
+
+ time.sleep(0.005)
+
+ def _model_has_param_sequence(self, model, seq: int) -> bool:
+ for param in self._iterate_model(model):
+ try:
+ if int(param.get_seq()) == seq:
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ @staticmethod
+ def _variant_to_string_dict(variant) -> dict[str, str]:
+ if variant is None:
+ return {}
+
+ try:
+ values = variant.unpack()
+ except AttributeError:
+ values = variant
+ except Exception:
+ return {}
+
+ try:
+ items = values.items()
+ except AttributeError:
+ return {}
+
+ result: dict[str, str] = {}
+ for key, value in items:
+ if key is not None and value is not None:
+ result[str(key)] = str(value)
+ return result
diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py
index a559276..b87724b 100644
--- a/src/mini_eq/routing.py
+++ b/src/mini_eq/routing.py
@@ -49,6 +49,7 @@
PipeWireNode,
node_sample_rate,
)
+from .pipewire_routes import PipeWireOutputPresetTarget
from .pipewire_stream_router import PipeWireStreamRouter
@@ -60,6 +61,8 @@ def __init__(self, output_sink: str | None) -> None:
self.original_default_sink = self.resolve_default_output_sink_name()
self.follow_default_output = output_sink is None
self.output_sink = output_sink or self.original_default_sink
+ self._output_preset_target_sink: str | None = None
+ self._output_preset_target: PipeWireOutputPresetTarget | None = None
self.filter_output_name = f"{self.virtual_sink_name}{FILTER_OUTPUT_SUFFIX}"
self.engine_module = None
self.filter_node_id: int | None = None
@@ -67,6 +70,8 @@ def __init__(self, output_sink: str | None) -> None:
self.output_object_added_handler_id = 0
self.output_object_removed_handler_id = 0
self.output_metadata_changed_handler_id = 0
+ self.output_route_param_handler_id = 0
+ self.output_route_param_device_id = 0
self.accept_output_events = False
self.routed = False
self.running = False
@@ -139,6 +144,27 @@ def get_sink(self, sink_name: str) -> PipeWireNode | None:
return self.output_backend.audio_sink_by_name(sink_name)
+ def output_preset_keys(self) -> tuple[str, ...]:
+ return self.output_preset_target().keys
+
+ def output_preset_link_key(self) -> str:
+ return self.output_preset_target().link_key
+
+ def invalidate_output_preset_target(self) -> None:
+ self._output_preset_target_sink = None
+ self._output_preset_target = None
+
+ def output_preset_target(self, *, refresh: bool = False) -> PipeWireOutputPresetTarget:
+ cached_target = getattr(self, "_output_preset_target", None)
+ cached_sink = getattr(self, "_output_preset_target_sink", None)
+ if not refresh and cached_target is not None and cached_sink == self.output_sink:
+ return cached_target
+
+ target = self.output_backend.output_preset_target_for_sink_name(self.output_sink)
+ self._output_preset_target_sink = self.output_sink
+ self._output_preset_target = target
+ return target
+
def default_output_sink_candidates(self, *, refresh: bool = False) -> tuple[str, ...]:
defaults = self.output_backend.refresh_defaults() if refresh else self.output_backend.defaults()
return tuple(
@@ -245,6 +271,8 @@ def switch_output_sink(self, sink_name: str, explicit: bool) -> None:
self.follow_default_output = False
self.output_sink = sink_name
+ self.invalidate_output_preset_target()
+ self.refresh_output_route_param_monitor()
if self.stream_router is not None:
self.stream_router.set_output_sink_name(sink_name)
if self.output_analyzer is not None:
@@ -253,13 +281,20 @@ def switch_output_sink(self, sink_name: str, explicit: bool) -> None:
self.output_analyzer.set_output_sink_name(sink_name, output_sink_description)
if self.retarget_filter_output():
+ if explicit:
+ self.schedule_output_event_refresh()
return
self.restart_engine()
+ if explicit:
+ self.schedule_output_event_refresh()
def follow_system_default_output(self) -> None:
+ previous_output_sink = getattr(self, "output_sink", None)
self.follow_default_output = True
self.refresh_followed_output_sink()
+ if getattr(self, "output_sink", None) != previous_output_sink:
+ self.schedule_output_event_refresh()
def refresh_followed_output_sink(self) -> bool:
if not self.follow_default_output:
@@ -277,7 +312,7 @@ def refresh_followed_output_sink(self) -> bool:
return True
def schedule_output_event_refresh(self) -> None:
- if not self.accept_output_events:
+ if not getattr(self, "accept_output_events", False):
return
if self.output_event_source_id == 0:
@@ -312,13 +347,51 @@ def handle_output_metadata_changed(
self.output_backend.remember_default_metadata_change(key, _value)
self.schedule_output_event_refresh()
+ def handle_output_route_param_changed(self) -> None:
+ self.schedule_output_event_refresh()
+
+ def refresh_output_route_param_monitor(self) -> None:
+ if not getattr(self, "accept_output_events", False):
+ return
+
+ output_sink_name = getattr(self, "output_sink", "")
+ if not output_sink_name:
+ self.disconnect_output_route_param_monitor()
+ return
+
+ output_sink = self.get_sink(output_sink_name)
+ device_id = output_sink.device_id if output_sink is not None else 0
+ if device_id == getattr(self, "output_route_param_device_id", 0):
+ return
+
+ self.disconnect_output_route_param_monitor()
+ self.output_route_param_device_id = device_id
+ if device_id <= 0:
+ return
+
+ self.output_route_param_handler_id = self.output_backend.connect_device_route_changed(
+ device_id,
+ self.handle_output_route_param_changed,
+ )
+ if self.output_route_param_handler_id == 0:
+ self.output_route_param_device_id = 0
+
+ def disconnect_output_route_param_monitor(self) -> None:
+ if getattr(self, "output_route_param_handler_id", 0) > 0:
+ self.output_backend.disconnect_device_handler(self.output_route_param_handler_id)
+ self.output_route_param_handler_id = 0
+
+ self.output_route_param_device_id = 0
+
def on_output_event_idle(self) -> bool:
self.output_event_source_id = 0
if not self.accept_output_events:
return False
+ self.invalidate_output_preset_target()
self.refresh_followed_output_sink()
+ self.refresh_output_route_param_monitor()
if self.outputs_changed_callback is not None:
self.outputs_changed_callback()
@@ -343,7 +416,9 @@ def start_output_event_monitoring(self) -> None:
self.handle_output_metadata_changed
)
+ self.invalidate_output_preset_target()
self.refresh_followed_output_sink()
+ self.refresh_output_route_param_monitor()
if self.outputs_changed_callback is not None:
self.outputs_changed_callback()
@@ -367,6 +442,8 @@ def stop_output_event_monitoring(self) -> None:
self.output_backend.disconnect_metadata_handler(self.output_metadata_changed_handler_id)
self.output_metadata_changed_handler_id = 0
+ self.disconnect_output_route_param_monitor()
+
def pick_virtual_sink_name(self) -> str:
existing = {sink.node_name for sink in self.list_sinks() if sink.node_name is not None}
diff --git a/src/mini_eq/style.css b/src/mini_eq/style.css
index a1f2043..fbe9860 100644
--- a/src/mini_eq/style.css
+++ b/src/mini_eq/style.css
@@ -59,6 +59,11 @@
min-height: 32px;
}
+.utility-pane-dense .toolbar-button {
+ padding: 2px 8px;
+ min-height: 30px;
+}
+
.toolbar-icon-button {
padding: 0;
min-width: 36px;
@@ -66,16 +71,14 @@
border-radius: 999px;
}
-.popover-action {
- min-width: 132px;
- padding: 6px 10px;
- border-radius: 10px;
-}
-
.toolbar-select {
min-height: 32px;
}
+.utility-pane-dense .toolbar-select {
+ min-height: 30px;
+}
+
.toolbar-compact-actions {
padding: 0;
border-radius: 0;
@@ -144,12 +147,26 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
background-color: var(--mini-utility-section-bg);
}
+.utility-pane-dense .utility-section {
+ padding: 5px 8px;
+ border-radius: 10px;
+}
+
.utility-row {
padding: 4px 0;
border-radius: 0;
background-color: transparent;
}
+.utility-pane-dense .utility-row,
+.utility-pane-dense .compare-row {
+ padding: 2px 0;
+}
+
+.output-scope-state-label {
+ color: alpha(var(--window-fg-color), 0.86);
+}
+
.compare-row {
padding: 4px 0;
border-radius: 0;
@@ -185,6 +202,49 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
margin-top: 2px;
}
+.preset-more-menu {
+ min-width: 190px;
+}
+
+.preset-menu-separator {
+ margin-top: 4px;
+ margin-bottom: 4px;
+ background-color: var(--mini-border-soft);
+}
+
+button.popover-action {
+ min-width: 178px;
+ min-height: 34px;
+ padding: 0 10px;
+ border-radius: 8px;
+ border-color: transparent;
+ background-color: transparent;
+ background-image: none;
+ box-shadow: none;
+}
+
+button.popover-action:hover {
+ background-color: alpha(var(--window-fg-color), 0.070);
+}
+
+button.popover-action:disabled {
+ opacity: 0.46;
+ background-color: transparent;
+ background-image: none;
+}
+
+button.popover-action label {
+ font-weight: 600;
+}
+
+button.popover-action.destructive-action:not(:disabled) {
+ color: var(--mini-danger-color);
+}
+
+.utility-pane-dense .preset-row {
+ margin-top: 0;
+}
+
.preset-state-chip {
padding: 3px 10px;
border-radius: 999px;
@@ -192,6 +252,13 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
font-weight: 800;
}
+.utility-pane-dense .preset-state-chip,
+.utility-pane-dense .system-state-chip,
+.utility-pane-dense .compare-state-chip,
+.utility-pane-dense .headroom-peak-chip {
+ padding: 2px 8px;
+}
+
.preset-state-saved {
background-color: rgba(78, 184, 109, 0.14);
color: var(--mini-success-color);
@@ -207,6 +274,11 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
color: alpha(var(--window-fg-color), 0.90);
}
+.preset-state-neutral {
+ background-color: rgba(82, 151, 233, 0.12);
+ color: var(--mini-toggle-checked-color);
+}
+
.system-state-chip {
padding: 3px 10px;
border-radius: 999px;
@@ -240,6 +312,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
background-color: transparent;
}
+.utility-pane-dense .headroom-panel {
+ padding: 2px 6px;
+}
+
.headroom-panel-risk {
background-color: rgba(255, 92, 80, 0.055);
}
@@ -250,6 +326,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
color: var(--window-fg-color);
}
+.utility-pane-dense .headroom-state {
+ font-size: 12pt;
+}
+
.headroom-peak-chip {
padding: 3px 9px;
border-radius: 999px;
@@ -283,6 +363,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
margin-top: 1px;
}
+.utility-pane-dense .headroom-preamp-row {
+ margin-top: 0;
+}
+
.headroom-preamp-row scale trough {
min-height: 4px;
}
@@ -293,16 +377,30 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
border-top: 1px solid var(--mini-border-subtle);
}
+.utility-pane-dense .monitor-strip {
+ margin-top: 0;
+ padding-top: 3px;
+}
+
.monitor-detail-row {
min-height: 24px;
}
+.utility-pane-dense .monitor-detail-row {
+ min-height: 20px;
+}
+
.monitor-settings-button {
min-width: 32px;
min-height: 32px;
border-radius: 8px;
}
+.utility-pane-dense .monitor-settings-button {
+ min-width: 30px;
+ min-height: 30px;
+}
+
.loudness-value-label {
min-width: 68px;
font-weight: 800;
@@ -313,6 +411,10 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane {
min-height: 14px;
}
+.utility-pane-dense .loudness-meter-area {
+ min-height: 12px;
+}
+
.headroom-state.headroom-safe {
color: var(--mini-success-color);
}
diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py
index a825316..646c559 100644
--- a/src/mini_eq/window.py
+++ b/src/mini_eq/window.py
@@ -44,7 +44,7 @@
from .window_headroom import MiniEqWindowHeadroomMixin, format_headroom_peak_db
from .window_layout import MiniEqWindowLayoutMixin
from .window_preferences import MiniEqWindowPreferencesMixin
-from .window_presets import MiniEqWindowPresetMixin
+from .window_presets import MiniEqWindowPresetMixin, imported_apo_curve_label
from .window_utility import MiniEqWindowUtilityPaneMixin
from .window_utils import requested_switch_state, set_switch_confirmed_state
@@ -81,7 +81,13 @@ class MiniEqWindow(
MiniEqWindowLayoutMixin,
Adw.ApplicationWindow,
):
- def __init__(self, app: Adw.Application, controller: SystemWideEqController, auto_route: bool) -> None:
+ def __init__(
+ self,
+ app: Adw.Application,
+ controller: SystemWideEqController,
+ auto_route: bool,
+ initial_curve_label: str | None = None,
+ ) -> None:
super().__init__(application=app, title=APP_NAME)
self.add_css_class("mini-eq-window")
self.controller = controller
@@ -117,7 +123,7 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut
self.curve_revert_baseline_label: str | None = None
self.curve_revert_baseline_signature: str | None = None
self.curve_revert_baseline_payload: dict[str, object] | None = None
- self.set_curve_revert_baseline("Neutral")
+ self.set_curve_revert_baseline(initial_curve_label or "Neutral")
self.output_preset_auto_applied = False
self.output_preset_curve_auto_loaded = False
self.updating_output_preset_switch = False
@@ -197,6 +203,7 @@ def __init__(self, app: Adw.Application, controller: SystemWideEqController, aut
self.graph_title_label = Gtk.Label(xalign=0.0)
self.graph_title_label.set_wrap(True)
self.preset_state_label = Gtk.Label(xalign=1.0)
+ self.output_scope_state_label = Gtk.Label(xalign=0.0)
self.output_preset_state_label = Gtk.Label(xalign=0.0)
self.output_preset_switch = Gtk.Switch()
self.headroom_peak_db: float | None = None
@@ -591,11 +598,31 @@ def output_display_name(self, sink: PipeWireNode | None) -> str:
return (
sink.property_value("device.description")
+ or sink.property_value("device.nick")
+ or sink.property_value("device.name")
or sink.node_description
or sink.node_name
or self.controller.output_sink
)
+ def output_sink_detail_name(self, sink: PipeWireNode | None, base_label: str) -> str:
+ if sink is None:
+ return "Unavailable"
+
+ node_description = str(sink.node_description or "").strip()
+ if node_description and node_description != base_label:
+ detail = node_description
+ if detail.casefold().startswith(base_label.casefold()):
+ detail = detail[len(base_label) :].strip().lstrip("-:").strip()
+ if (detail.startswith("(") and detail.endswith(")")) or (
+ detail.startswith("[") and detail.endswith("]")
+ ):
+ detail = detail[1:-1].strip()
+ if detail:
+ return detail
+
+ return self.transport_label_for_sink(sink)
+
def list_visible_output_sinks(self) -> list[PipeWireNode]:
return [
sink
@@ -616,7 +643,7 @@ def build_output_sink_labels(self, sinks: list[PipeWireNode]) -> list[str]:
resolved.append(label)
continue
- resolved.append(f"{label} ({self.transport_label_for_sink(sink)} • {self.format_sample_spec(sink)})")
+ resolved.append(f"{label} ({self.output_sink_detail_name(sink, label)} • {self.format_sample_spec(sink)})")
return resolved
@@ -724,7 +751,7 @@ def update_status_summary(self) -> None:
self.system_state_label.set_text("Standby")
self.system_state_label.add_css_class("system-state-idle")
- def refresh_output_sinks(self, *, auto_apply_output_preset: bool = True) -> None:
+ def refresh_output_sinks(self, *, handle_observed_output_change: bool = True) -> None:
if self.ui_shutting_down:
return
@@ -755,12 +782,15 @@ def refresh_output_sinks(self, *, auto_apply_output_preset: bool = True) -> None
self.updating_output_combo = False
output_changed = previous_output is not None and previous_output != active
- self.last_output_preset_sink_name = active
+ # App-originated selector refreshes should not consume the output
+ # transition; the next PipeWire-observed refresh owns preset handling.
+ if handle_observed_output_change or previous_output is None:
+ self.last_output_preset_sink_name = active
self.update_preset_state()
self.update_info_label()
self.update_status_summary()
- if self.post_present_ready and auto_apply_output_preset and output_changed:
+ if self.post_present_ready and handle_observed_output_change and output_changed:
self.apply_output_preset_for_current_output(
reset_auto_preset_without_link=previous_output_preset_auto_loaded,
announce_no_output_preset=True,
@@ -794,27 +824,20 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) ->
try:
imported_count = self.controller.import_apo_preset(path)
+ curve_label = imported_apo_curve_label(path)
self.selected_band_index = None
self.set_visible_band_count(imported_count)
self.current_preset_name = None
self.saved_preset_signature = self.controller.state_signature()
- self.set_curve_revert_baseline("Imported APO Preset")
+ self.set_curve_revert_baseline(curve_label)
self.output_preset_curve_auto_loaded = False
+ self.refresh_preset_list()
self.sync_ui_from_state()
+ self.set_status(curve_label)
+ self.notify_control_state_changed()
except Exception as exc:
self.set_status(str(exc))
- def on_clear_clicked(self, button: Gtk.Button) -> None:
- self.controller.reset_state()
- self.selected_band_index = None
- self.set_visible_band_count(DEFAULT_ACTIVE_BANDS)
- self.current_preset_name = None
- self.saved_preset_signature = self.controller.state_signature()
- self.set_curve_revert_baseline("Neutral")
- self.output_preset_curve_auto_loaded = False
- self.sync_ui_from_state()
- self.set_status("Equalizer Reset")
-
def on_output_changed(self, combo: Gtk.DropDown, _param: object) -> None:
if self.updating_output_combo:
return
@@ -824,26 +847,15 @@ def on_output_changed(self, combo: Gtk.DropDown, _param: object) -> None:
return
sink_name = self.output_sink_names[selected]
- previous_output_preset_auto_loaded = self.output_preset_curve_auto_loaded
try:
if sink_name is None:
self.controller.follow_system_default_output()
- self.refresh_output_sinks(auto_apply_output_preset=False)
- if not self.apply_output_preset_for_current_output(
- reset_auto_preset_without_link=previous_output_preset_auto_loaded,
- announce_no_output_preset=True,
- ):
- self.set_status("EQ Output Follows System Output")
+ self.refresh_output_sinks(handle_observed_output_change=False)
return
self.controller.change_output_sink(sink_name)
- self.refresh_output_sinks(auto_apply_output_preset=False)
- if not self.apply_output_preset_for_current_output(
- reset_auto_preset_without_link=previous_output_preset_auto_loaded,
- announce_no_output_preset=True,
- ):
- self.set_status("EQ Output Updated")
+ self.refresh_output_sinks(handle_observed_output_change=False)
except Exception as exc:
self.set_status(str(exc))
diff --git a/src/mini_eq/window_layout.py b/src/mini_eq/window_layout.py
index 7890945..743daa4 100644
--- a/src/mini_eq/window_layout.py
+++ b/src/mini_eq/window_layout.py
@@ -29,6 +29,7 @@
from .window_utils import (
bind_label_to_control,
constrain_editor_label,
+ make_ellipsizing_string_list_factory,
set_accessible_description,
set_accessible_label,
)
@@ -56,6 +57,7 @@
DEFAULT_FADER_SCROLLER_MIN_HEIGHT = 200
COMPACT_FADER_SCROLLER_MIN_HEIGHT = 150
ROOMY_FADER_SCROLLER_MIN_HEIGHT = 290
+UTILITY_DENSE_HEIGHT = 660
class MiniEqWindowLayoutMixin:
@@ -106,6 +108,8 @@ def build_window_content(self, auto_route: bool) -> None:
self.output_combo.set_hexpand(False)
self.output_combo.set_size_request(300, -1)
self.output_combo.add_css_class("toolbar-select")
+ self.output_combo.set_factory(make_ellipsizing_string_list_factory(34))
+ self.output_combo.set_list_factory(make_ellipsizing_string_list_factory(42))
set_accessible_label(self.output_combo, "EQ output")
bind_label_to_control(output_label, self.output_combo)
output_inline.append(self.output_combo)
@@ -125,7 +129,6 @@ def add_window_action(action_name: str, callback) -> None:
self.add_action(action)
add_window_action("import-apo", lambda: self.on_import_apo_clicked(tools_button))
- add_window_action("reset-eq", lambda: self.on_clear_clicked(tools_button))
add_window_action("preferences", self.show_preferences_dialog)
add_window_action("about", self.show_about_dialog)
self.appearance_action = Gio.SimpleAction.new_stateful(
@@ -138,7 +141,6 @@ def add_window_action(action_name: str, callback) -> None:
tools_menu = Gio.Menu()
tools_menu.append("Import Equalizer APO…", "win.import-apo")
- tools_menu.append("Reset EQ", "win.reset-eq")
appearance_menu = Gio.Menu()
appearance_menu.append("Follow System", "win.appearance::system")
@@ -623,6 +625,10 @@ def sync_visual_layout(height: int | None = None) -> None:
ROOMY_FADER_SCROLLER_MIN_HEIGHT,
layout_height,
)
+ if layout_height <= UTILITY_DENSE_HEIGHT:
+ right_column.add_css_class("utility-pane-dense")
+ else:
+ right_column.remove_css_class("utility-pane-dense")
right_column.set_spacing(responsive_value(6, 12, layout_height))
right_column.set_margin_top(responsive_value(2, 4, layout_height))
right_column.set_margin_bottom(responsive_value(0, 2, layout_height))
diff --git a/src/mini_eq/window_presets.py b/src/mini_eq/window_presets.py
index 130f655..5ff23c2 100644
--- a/src/mini_eq/window_presets.py
+++ b/src/mini_eq/window_presets.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
+from dataclasses import dataclass
from pathlib import Path
import gi
@@ -31,11 +32,121 @@
)
from .window_utils import requested_switch_state, set_switch_confirmed_state
+APO_IMPORT_LABEL_PREFIX = "Imported APO: "
+DELETED_PRESET_LABEL_PREFIX = "Unsaved copy: "
+
+
+def imported_apo_curve_label(path: str) -> str:
+ preset_name = sanitize_preset_name(Path(path).stem)
+ if preset_name:
+ return f"{APO_IMPORT_LABEL_PREFIX}{preset_name}"
+ return "Imported APO"
+
+
+@dataclass(frozen=True)
+class PresetPanelUiState:
+ preset_state_text: str
+ preset_state_class: str
+ preset_state_tooltip: str
+ save_label: str
+ save_as_visible: bool
+ revert_visible: bool
+ revert_label: str
+ revert_tooltip: str
+ reset_visible: bool
+ reset_tooltip: str
+ default_set_visible: bool
+ default_clear_visible: bool
+ default_separator_visible: bool
+ file_separator_visible: bool
+ library_separator_visible: bool
+ export_label: str
+ delete_visible: bool
+
class MiniEqWindowPresetMixin:
+ def output_preset_target(self):
+ try:
+ return self.controller.output_preset_target()
+ except Exception:
+ return None
+
+ def output_preset_keys(self, target=None) -> tuple[str, ...]:
+ if target is not None:
+ keys = tuple(getattr(target, "keys", ()))
+ if keys:
+ return keys
+
+ output_sink = getattr(self.controller, "output_sink", None)
+ try:
+ keys = tuple(self.controller.output_preset_keys())
+ except Exception:
+ keys = ()
+
+ if keys:
+ return keys
+ return (output_sink,) if output_sink else ()
+
+ def output_preset_link_key(self, target=None) -> str:
+ if target is not None:
+ link_key = str(getattr(target, "link_key", "") or "").strip()
+ if link_key:
+ return link_key
+
+ try:
+ return self.controller.output_preset_link_key()
+ except Exception:
+ return getattr(self.controller, "output_sink", "") or ""
+
+ def output_preset_has_route(self, target=None) -> bool:
+ return bool(getattr(target, "has_route_key", False))
+
+ def output_preset_scope_text(self, target=None) -> tuple[str, str, str]:
+ if self.output_preset_has_route(target):
+ return "port", "port", "Port"
+ return "EQ output", "output", "EQ Output"
+
+ def update_output_scope_state(self, target=None) -> None:
+ label = getattr(self, "output_scope_state_label", None)
+ scope_label = getattr(self, "output_preset_scope_label", None)
+
+ if scope_label is not None:
+ scope_label.set_text("Auto Preset")
+
+ if label is None:
+ return
+
+ output_sink = getattr(self.controller, "output_sink", None)
+ route = getattr(target, "route", None)
+ route_name = None
+ if route is not None:
+ route_name = getattr(route, "description", None) or getattr(route, "name", None)
+
+ if self.output_preset_has_route(target):
+ route_key = getattr(route, "output_preset_key", None)
+ route_id = getattr(route, "name", None)
+ route_text = str(route_name or route_id or "current output port")
+ label.set_text(route_text)
+ tooltip_parts = [f"Auto presets use {route_text}."]
+ if route_id and route_name and route_id != route_name:
+ tooltip_parts.append(f"Route: {route_id}")
+ if route_key:
+ tooltip_parts.append("The preset link is tied to this detected port.")
+ label.set_tooltip_text("\n".join(tooltip_parts))
+ return
+
+ if output_sink:
+ label.set_text("Output-wide")
+ label.set_tooltip_text("No reliable port route was reported; auto preset links use the selected EQ output.")
+ return
+
+ label.set_text("No output")
+ label.set_tooltip_text("Select an EQ output before linking an auto preset.")
+
def output_preset_link_name(self) -> str | None:
try:
- return get_output_preset_link(self.controller.output_sink)
+ target = self.output_preset_target()
+ return get_output_preset_link(self.output_preset_keys(target))
except Exception:
return None
@@ -71,6 +182,74 @@ def has_curve_revert_changes(self) -> bool:
revert_signature = self.curve_revert_signature()
return revert_signature is not None and self.controller.state_signature() != revert_signature
+ def curve_revert_target_is_neutral(self) -> bool:
+ return self.current_preset_name is None and self.curve_revert_signature() == self.default_preset_signature
+
+ def current_curve_source_label(self) -> str | None:
+ if self.current_preset_name is not None:
+ return None
+
+ label = self.curve_revert_label()
+ signature = self.curve_revert_signature()
+ if not label or signature is None or signature == self.default_preset_signature:
+ return None
+ return label
+
+ def suggested_save_as_name(self) -> str:
+ if self.current_preset_name is not None:
+ return self.current_preset_name
+
+ label = self.current_curve_source_label()
+ if label and label.startswith(APO_IMPORT_LABEL_PREFIX):
+ return sanitize_preset_name(label[len(APO_IMPORT_LABEL_PREFIX) :])
+ if label and label.startswith(DELETED_PRESET_LABEL_PREFIX):
+ return sanitize_preset_name(label[len(DELETED_PRESET_LABEL_PREFIX) :])
+ if label == "Imported APO":
+ return label
+ return ""
+
+ def preset_name_exists(self, name: str) -> bool:
+ return preset_path_for_name(name).exists()
+
+ def confirm_preset_replacement(
+ self,
+ preset_name: str,
+ body: str,
+ replace_callback: Callable[[], None],
+ ) -> None:
+ dialog = Adw.AlertDialog()
+ dialog.set_heading("Replace preset?")
+ dialog.set_body(body)
+ dialog.add_response("cancel", "Cancel")
+ dialog.add_response("replace", "Replace")
+ dialog.set_default_response("cancel")
+ dialog.set_close_response("cancel")
+ dialog.set_response_appearance("replace", Adw.ResponseAppearance.DESTRUCTIVE)
+ dialog.choose(
+ self,
+ None,
+ lambda dialog, result: self.on_preset_replace_dialog_done(dialog, result, replace_callback),
+ )
+
+ def on_preset_replace_dialog_done(
+ self,
+ dialog: Adw.AlertDialog,
+ result: Gio.AsyncResult,
+ replace_callback: Callable[[], None],
+ ) -> None:
+ try:
+ response = dialog.choose_finish(result)
+ except GLib.Error:
+ return
+
+ if response != "replace":
+ return
+
+ try:
+ replace_callback()
+ except Exception as exc:
+ self.set_status(str(exc))
+
def output_preset_is_active(self) -> bool:
linked_preset = self.output_preset_link_name()
return bool(
@@ -85,6 +264,73 @@ def has_unsaved_curve_changes(self) -> bool:
return self.controller.state_signature() != self.saved_preset_signature
+ def has_neutral_curve_changes(self) -> bool:
+ return self.controller.state_signature() != self.default_preset_signature
+
+ def preset_panel_ui_state(self) -> PresetPanelUiState:
+ current_signature = self.controller.state_signature()
+ has_named_preset = self.current_preset_name is not None
+ neutral = current_signature == self.default_preset_signature
+ revert_signature = self.curve_revert_signature()
+ revert_label = self.curve_revert_label() or "curve baseline"
+ has_revert_target = revert_signature is not None and not self.curve_revert_target_is_neutral()
+ revert_visible = has_revert_target and current_signature != revert_signature
+ reset_visible = not neutral
+ default_preset = self.default_preset_name()
+ default_set_visible = has_named_preset
+ default_clear_visible = default_preset is not None
+ curve_group_visible = has_named_preset or revert_visible or reset_visible
+ default_group_visible = default_set_visible or default_clear_visible
+ export_label = "Export Preset…" if has_named_preset else "Export Current Curve…"
+
+ if has_named_preset and current_signature == self.saved_preset_signature:
+ preset_state_text = "Saved"
+ preset_state_class = "preset-state-saved"
+ preset_state_tooltip = f"{self.current_preset_name} matches the saved preset"
+ elif has_named_preset:
+ preset_state_text = "Modified"
+ preset_state_class = "preset-state-modified"
+ preset_state_tooltip = f"{self.current_preset_name} has unsaved curve changes"
+ elif neutral:
+ preset_state_text = "Neutral"
+ preset_state_class = "preset-state-neutral"
+ preset_state_tooltip = "Current curve is neutral"
+ elif revert_signature is not None and current_signature == revert_signature:
+ preset_state_text = "Unsaved"
+ preset_state_class = "preset-state-unsaved"
+ preset_state_tooltip = "Current curve has not been saved as a preset"
+ else:
+ preset_state_text = "Modified"
+ preset_state_class = "preset-state-modified"
+ preset_state_tooltip = "Current curve has unsaved changes"
+
+ if revert_visible:
+ revert_tooltip = f"Revert to {revert_label}"
+ elif has_revert_target:
+ revert_tooltip = "No curve changes to revert"
+ else:
+ revert_tooltip = "No preset baseline to revert to"
+
+ return PresetPanelUiState(
+ preset_state_text=preset_state_text,
+ preset_state_class=preset_state_class,
+ preset_state_tooltip=preset_state_tooltip,
+ save_label="Save" if has_named_preset else "Save As…",
+ save_as_visible=has_named_preset,
+ revert_visible=revert_visible,
+ revert_label=f"Revert to {revert_label}",
+ revert_tooltip=revert_tooltip,
+ reset_visible=reset_visible,
+ reset_tooltip="Reset all bands and preamp to neutral" if reset_visible else "Curve is already neutral",
+ default_set_visible=default_set_visible,
+ default_clear_visible=default_clear_visible,
+ default_separator_visible=curve_group_visible and default_group_visible,
+ file_separator_visible=curve_group_visible or default_group_visible,
+ library_separator_visible=has_named_preset,
+ export_label=export_label,
+ delete_visible=has_named_preset,
+ )
+
def update_output_preset_state(self) -> None:
label = getattr(self, "output_preset_state_label", None)
if label is None:
@@ -92,6 +338,11 @@ def update_output_preset_state(self) -> None:
switch = getattr(self, "output_preset_switch", None)
self.output_preset_auto_applied = False
+ target = self.output_preset_target()
+ self.update_output_scope_state(target)
+ scope_text, scope_kind, _status_scope = self.output_preset_scope_text(target)
+ linked_status_text = "Linked to port" if scope_kind == "port" else "Linked to EQ output"
+ clear_tooltip = f"Clear auto preset for {scope_text}"
def sync_output_preset_switch(
*,
@@ -116,12 +367,12 @@ def sync_output_preset_switch(
switch.set_tooltip_text(tooltip)
try:
- linked_preset = get_output_preset_link(self.controller.output_sink)
+ linked_preset = get_output_preset_link(self.output_preset_keys(target))
except Exception as exc:
sync_output_preset_switch(
active=False,
sensitive=False,
- tooltip="Output preset links are unavailable",
+ tooltip="Auto preset links are unavailable",
status_text="Unavailable",
status_tooltip=str(exc),
)
@@ -136,7 +387,7 @@ def sync_output_preset_switch(
elif not has_named_preset:
tooltip = "Save a Preset First"
else:
- tooltip = "Use Selected Preset for This EQ Output"
+ tooltip = f"Use selected preset automatically for {scope_text}"
sync_output_preset_switch(
active=False,
sensitive=has_output and has_named_preset,
@@ -144,12 +395,18 @@ def sync_output_preset_switch(
)
return
- self.output_preset_auto_applied = self.output_preset_is_active()
+ self.output_preset_auto_applied = bool(
+ linked_preset
+ and self.current_preset_name == linked_preset
+ and self.controller.state_signature() == self.saved_preset_signature
+ )
if self.output_preset_auto_applied:
sync_output_preset_switch(
active=True,
sensitive=has_output,
- tooltip="Clear Output Preset",
+ tooltip=clear_tooltip,
+ status_text=linked_status_text,
+ status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}",
)
return
@@ -157,37 +414,66 @@ def sync_output_preset_switch(
sync_output_preset_switch(
active=True,
sensitive=has_output,
- tooltip="Clear Output Preset",
+ tooltip=clear_tooltip,
status_text="Different",
- status_tooltip=f"This output currently uses {linked_preset}",
+ status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}",
)
return
sync_output_preset_switch(
active=True,
sensitive=has_output,
- tooltip="Clear Output Preset",
- status_text="Linked",
- status_tooltip=f"This output uses {linked_preset}",
+ tooltip=clear_tooltip,
+ status_text=linked_status_text,
+ status_tooltip=f"Auto preset for {scope_text} uses {linked_preset}",
)
- def refresh_preset_actions(self) -> None:
- has_named_preset = self.current_preset_name is not None
- has_revert_target = self.curve_revert_signature() is not None
- has_revert_changes = self.has_curve_revert_changes()
- revert_label = self.curve_revert_label() or "curve baseline"
- self.preset_delete_button.set_sensitive(has_named_preset)
- self.preset_export_button.set_sensitive(True)
- self.preset_import_button.set_sensitive(True)
- self.preset_revert_button.set_sensitive(has_revert_changes)
- if not has_revert_target:
- self.preset_revert_button.set_tooltip_text("Load or import a preset first")
- elif has_revert_changes:
- self.preset_revert_button.set_tooltip_text(f"Revert to {revert_label}")
- else:
- self.preset_revert_button.set_tooltip_text("No curve changes to revert")
+ def set_preset_widget_visible(self, name: str, visible: bool) -> None:
+ widget = getattr(self, name, None)
+ if widget is not None:
+ widget.set_visible(visible)
+
+ def set_preset_widget_label(self, name: str, text: str) -> None:
+ label = getattr(self, f"{name}_label", None)
+ if label is not None:
+ label.set_text(text)
+ return
+
+ widget = getattr(self, name, None)
+ if widget is not None:
+ widget.set_label(text)
+
+ def refresh_preset_actions(self, state: PresetPanelUiState | None = None) -> None:
+ state = state or self.preset_panel_ui_state()
+
+ self.set_preset_widget_label("preset_save_button", state.save_label)
self.preset_save_button.set_sensitive(True)
+
+ self.set_preset_widget_visible("preset_save_as_button", state.save_as_visible)
self.preset_save_as_button.set_sensitive(True)
+
+ self.set_preset_widget_visible("preset_revert_button", state.revert_visible)
+ self.set_preset_widget_label("preset_revert_button", state.revert_label)
+ self.preset_revert_button.set_sensitive(state.revert_visible)
+ self.preset_revert_button.set_tooltip_text(state.revert_tooltip)
+
+ self.set_preset_widget_visible("preset_reset_to_neutral_button", state.reset_visible)
+ self.preset_reset_to_neutral_button.set_sensitive(state.reset_visible)
+ self.preset_reset_to_neutral_button.set_tooltip_text(state.reset_tooltip)
+
+ self.set_preset_widget_visible("default_preset_set_button", state.default_set_visible)
+ self.set_preset_widget_visible("default_preset_clear_button", state.default_clear_visible)
+
+ self.set_preset_widget_visible("preset_default_separator", state.default_separator_visible)
+ self.set_preset_widget_visible("preset_file_separator", state.file_separator_visible)
+ self.set_preset_widget_visible("preset_library_separator", state.library_separator_visible)
+
+ self.preset_export_button.set_sensitive(True)
+ self.set_preset_widget_label("preset_export_button", state.export_label)
+ self.preset_import_button.set_sensitive(True)
+
+ self.set_preset_widget_visible("preset_delete_button", state.delete_visible)
+ self.preset_delete_button.set_sensitive(state.delete_visible)
self.update_output_preset_state()
self.update_default_preset_state()
@@ -224,7 +510,7 @@ def update_default_preset_state(self) -> None:
if default_preset in self.preset_names:
label.set_text(default_preset)
- label.set_tooltip_text("Used when the selected output has no preset")
+ label.set_tooltip_text("Used when the selected output has no auto preset")
return
label.set_text("Missing")
@@ -232,12 +518,13 @@ def update_default_preset_state(self) -> None:
def refresh_preset_list(self) -> None:
self.preset_names = list_preset_names()
- self.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_names)
selected_index = Gtk.INVALID_LIST_POSITION
if self.current_preset_name in self.preset_names:
selected_index = self.preset_names.index(self.current_preset_name)
+ self.preset_model.splice(0, self.preset_model.get_n_items(), self.preset_names)
+
self.updating_preset_combo = True
try:
self.preset_combo.set_selected(selected_index)
@@ -247,32 +534,38 @@ def refresh_preset_list(self) -> None:
self.update_preset_state()
def update_preset_state(self) -> None:
- current_signature = self.controller.state_signature()
- current_name = self.current_preset_name or "Current State"
+ state = self.preset_panel_ui_state()
self.preset_state_label.remove_css_class("preset-state-saved")
self.preset_state_label.remove_css_class("preset-state-modified")
self.preset_state_label.remove_css_class("preset-state-unsaved")
+ self.preset_state_label.remove_css_class("preset-state-neutral")
- if self.current_preset_name is None and self.has_curve_revert_changes():
- revert_label = self.curve_revert_label() or "Current curve"
- self.preset_state_label.set_text("Modified")
- self.preset_state_label.add_css_class("preset-state-modified")
- self.preset_state_label.set_tooltip_text(f"{revert_label} has unsaved curve changes")
- elif self.current_preset_name is None:
- self.preset_state_label.set_text("Unsaved")
- self.preset_state_label.add_css_class("preset-state-unsaved")
- self.preset_state_label.set_tooltip_text("Current curve has not been saved as a preset")
- elif current_signature == self.saved_preset_signature:
- self.preset_state_label.set_text("Saved")
- self.preset_state_label.add_css_class("preset-state-saved")
- self.preset_state_label.set_tooltip_text(f"{current_name} matches the saved preset")
- else:
- self.preset_state_label.set_text("Modified")
- self.preset_state_label.add_css_class("preset-state-modified")
- self.preset_state_label.set_tooltip_text(f"{current_name} has unsaved curve changes")
+ self.preset_state_label.set_text(state.preset_state_text)
+ self.preset_state_label.add_css_class(state.preset_state_class)
+ self.preset_state_label.set_tooltip_text(state.preset_state_tooltip)
+
+ self.update_current_curve_state()
+ self.refresh_preset_actions(state)
+
+ def update_current_curve_state(self) -> None:
+ label = getattr(self, "current_curve_state_label", None)
+ row = getattr(self, "current_curve_row", None)
+ if label is None:
+ return
- self.refresh_preset_actions()
+ current_curve_label = self.current_curve_source_label()
+ if current_curve_label is None:
+ label.set_text("")
+ label.set_tooltip_text("No provisional curve source")
+ if row is not None:
+ row.set_visible(False)
+ return
+
+ label.set_text(current_curve_label)
+ label.set_tooltip_text(f"{current_curve_label}\nUse Save As to add it to the preset library.")
+ if row is not None:
+ row.set_visible(True)
def save_current_state_to_preset(self, name: str) -> None:
preset_name = sanitize_preset_name(name)
@@ -291,6 +584,21 @@ def save_current_state_to_preset(self, name: str) -> None:
self.notify_control_presets_changed()
self.notify_control_state_changed()
+ def save_current_state_to_preset_as(self, name: str) -> None:
+ preset_name = sanitize_preset_name(name)
+ if not preset_name:
+ raise ValueError("Preset name is empty")
+
+ if preset_name != self.current_preset_name and self.preset_name_exists(preset_name):
+ self.confirm_preset_replacement(
+ preset_name,
+ f"{preset_name} already exists. Replace it with the current curve?",
+ lambda: self.save_current_state_to_preset(preset_name),
+ )
+ return
+
+ self.save_current_state_to_preset(preset_name)
+
def load_library_preset(
self,
name: str,
@@ -314,19 +622,33 @@ def load_library_preset(
if status_message is not None:
self.set_status(status_message)
elif auto:
- self.set_status(f"Applied Output Preset: {preset_name}")
+ self.set_status(f"Applied Auto Preset: {preset_name}")
else:
self.set_status(f"Loaded Preset: {preset_name}")
self.notify_control_state_changed()
+ def reset_curve_to_neutral(self, status_message: str = "Reset to Neutral") -> None:
+ self.controller.reset_state()
+ self.current_preset_name = None
+ self.saved_preset_signature = self.controller.state_signature()
+ self.set_curve_revert_baseline("Neutral")
+ self.selected_band_index = None
+ self.set_visible_band_count(DEFAULT_ACTIVE_BANDS)
+ self.output_preset_curve_auto_loaded = False
+ self.output_preset_auto_applied = False
+ self.sync_ui_from_state()
+ self.set_status(status_message)
+ self.notify_control_state_changed()
+
def apply_output_preset_for_current_output(
self,
*,
reset_auto_preset_without_link: bool = False,
announce_no_output_preset: bool = False,
) -> bool:
+ target = self.output_preset_target()
try:
- linked_preset = get_output_preset_link(self.controller.output_sink)
+ linked_preset = get_output_preset_link(self.output_preset_keys(target))
except Exception as exc:
self.update_preset_state()
self.set_status(str(exc))
@@ -339,7 +661,7 @@ def apply_output_preset_for_current_output(
self.output_preset_curve_auto_loaded = False
self.update_preset_state()
if announce_no_output_preset:
- self.set_status("Kept Unsaved Changes")
+ self.set_status("No Auto Preset: Kept Unsaved Changes")
self.notify_control_state_changed()
return announce_no_output_preset
@@ -347,14 +669,13 @@ def apply_output_preset_for_current_output(
should_apply_default_preset = default_preset is not None and (
reset_auto_preset_without_link or self.current_preset_name is None
)
- # TODO(#8): Prefer a port-specific fallback profile here when route matching is available.
if should_apply_default_preset:
try:
self.load_library_preset(
default_preset,
auto=True,
output_preset_auto=False,
- status_message="Applied Default Preset",
+ status_message="No Auto Preset: Applied Default Preset",
)
except Exception:
self.output_preset_auto_applied = False
@@ -365,24 +686,14 @@ def apply_output_preset_for_current_output(
return True
if reset_auto_preset_without_link:
- self.controller.reset_state()
- self.current_preset_name = None
- self.saved_preset_signature = self.controller.state_signature()
- self.set_curve_revert_baseline("Neutral")
- self.selected_band_index = None
- self.set_visible_band_count(DEFAULT_ACTIVE_BANDS)
- self.output_preset_curve_auto_loaded = False
- self.output_preset_auto_applied = False
- self.sync_ui_from_state()
- self.set_status("Reset to Neutral")
- self.notify_control_state_changed()
+ self.reset_curve_to_neutral("No Auto Preset: Reset to Neutral")
return True
self.output_preset_auto_applied = False
self.output_preset_curve_auto_loaded = False
self.update_preset_state()
if announce_no_output_preset:
- self.set_status("Kept Current Curve")
+ self.set_status("No Auto Preset: Curve Unchanged")
self.notify_control_state_changed()
return announce_no_output_preset
@@ -400,7 +711,7 @@ def apply_output_preset_for_current_output(
self.output_preset_auto_applied = False
self.output_preset_curve_auto_loaded = False
self.update_preset_state()
- self.set_status(f"Output Preset Unavailable: {linked_preset}")
+ self.set_status(f"Auto Preset Unavailable: {linked_preset}")
self.notify_control_state_changed()
return True
@@ -504,8 +815,8 @@ def on_preset_save_clicked(self, button: Gtk.Button) -> None:
self.on_preset_save_as_clicked(button)
def on_preset_save_as_clicked(self, button: Gtk.Button) -> None:
- initial_name = self.current_preset_name or ""
- self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset)
+ initial_name = self.suggested_save_as_name()
+ self.prompt_for_preset_name("Save Preset As", "Save", initial_name, self.save_current_state_to_preset_as)
def on_preset_revert_clicked(self, button: Gtk.Button) -> None:
if self.current_preset_name is not None:
@@ -537,17 +848,25 @@ def on_preset_revert_clicked(self, button: Gtk.Button) -> None:
except Exception as exc:
self.set_status(str(exc))
+ def on_preset_reset_to_neutral_clicked(self, _button: Gtk.Button) -> None:
+ try:
+ self.reset_curve_to_neutral()
+ except Exception as exc:
+ self.set_status(str(exc))
+
def on_use_preset_for_output_clicked(self, _button: Gtk.Widget) -> None:
if self.current_preset_name is None:
self.set_status("No Preset Selected")
return
+ target = self.output_preset_target()
+ _scope_text, _scope_kind, status_scope = self.output_preset_scope_text(target)
try:
- preset_name = set_output_preset_link(self.controller.output_sink, self.current_preset_name)
+ preset_name = set_output_preset_link(self.output_preset_link_key(target), self.current_preset_name)
self.output_preset_auto_applied = self.output_preset_is_active()
self.output_preset_curve_auto_loaded = False
self.update_preset_state()
- self.set_status(f"Linked Output Preset: {preset_name}")
+ self.set_status(f"Linked Auto Preset to {status_scope}: {preset_name}")
self.notify_control_state_changed()
except Exception as exc:
self.set_status(str(exc))
@@ -582,15 +901,17 @@ def on_clear_default_preset_clicked(self, _button: Gtk.Widget) -> None:
self.notify_control_state_changed()
def on_clear_output_preset_link_clicked(self, _button: Gtk.Widget) -> None:
+ target = self.output_preset_target()
+ _scope_text, _scope_kind, status_scope = self.output_preset_scope_text(target)
try:
- removed = clear_output_preset_link(self.controller.output_sink)
+ removed = clear_output_preset_link(self.output_preset_keys(target))
self.output_preset_auto_applied = False
self.output_preset_curve_auto_loaded = False
self.update_preset_state()
if removed:
- self.set_status(f"Cleared Output Preset: {removed}")
+ self.set_status(f"Cleared Auto Preset from {status_scope}: {removed}")
else:
- self.set_status("No Output Preset")
+ self.set_status(f"No Auto Preset for {status_scope}")
self.notify_control_state_changed()
except Exception as exc:
self.set_status(str(exc))
@@ -620,7 +941,7 @@ def on_preset_delete_clicked(self, button: Gtk.Button) -> None:
preset_name = self.current_preset_name
dialog = Adw.AlertDialog()
dialog.set_heading("Delete preset?")
- dialog.set_body(f"{preset_name} will be removed from your preset library.")
+ dialog.set_body(f"{preset_name} will be removed from your preset library. The current curve will stay active.")
dialog.add_response("cancel", "Cancel")
dialog.add_response("delete", "Delete")
dialog.set_default_response("cancel")
@@ -646,10 +967,10 @@ def on_preset_delete_dialog_done(
delete_preset_file(preset_name)
self.current_preset_name = None
self.saved_preset_signature = self.controller.state_signature()
- self.clear_curve_revert_baseline()
+ self.set_curve_revert_baseline(f"{DELETED_PRESET_LABEL_PREFIX}{preset_name}")
self.refresh_preset_list()
self.sync_ui_from_state()
- self.set_status(f"Deleted Preset: {preset_name}")
+ self.set_status(f"Deleted Preset: {preset_name}; Current Curve Kept")
self.notify_control_presets_changed()
self.notify_control_state_changed()
except Exception as exc:
@@ -666,6 +987,20 @@ def on_preset_import_clicked(self, button: Gtk.Button) -> None:
dialog.set_default_filter(file_filter)
dialog.open(self, None, self.on_preset_import_done)
+ def import_library_preset_payload(self, preset_name: str, payload: dict[str, object]) -> None:
+ write_mini_eq_preset_file(preset_path_for_name(preset_name), payload)
+ self.controller.apply_preset_payload(payload)
+ self.selected_band_index = None
+ self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands))
+ self.current_preset_name = preset_name
+ self.saved_preset_signature = self.controller.state_signature()
+ self.set_curve_revert_baseline(preset_name)
+ self.refresh_preset_list()
+ self.sync_ui_from_state()
+ self.set_status(f"Imported Preset: {preset_name}")
+ self.notify_control_presets_changed()
+ self.notify_control_state_changed()
+
def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
try:
file = dialog.open_finish(result)
@@ -686,18 +1021,15 @@ def on_preset_import_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult)
stored_payload = dict(payload)
stored_payload["version"] = PRESET_VERSION
stored_payload["name"] = preset_name
- write_mini_eq_preset_file(preset_path_for_name(preset_name), stored_payload)
- self.controller.apply_preset_payload(stored_payload)
- self.selected_band_index = None
- self.set_visible_band_count(fader_band_count_for_profile(self.controller.bands))
- self.current_preset_name = preset_name
- self.saved_preset_signature = self.controller.state_signature()
- self.set_curve_revert_baseline(preset_name)
- self.refresh_preset_list()
- self.sync_ui_from_state()
- self.set_status(f"Imported Preset: {preset_name}")
- self.notify_control_presets_changed()
- self.notify_control_state_changed()
+ if self.preset_name_exists(preset_name):
+ self.confirm_preset_replacement(
+ preset_name,
+ f"{preset_name} already exists. Replace it with the imported preset?",
+ lambda: self.import_library_preset_payload(preset_name, stored_payload),
+ )
+ return
+
+ self.import_library_preset_payload(preset_name, stored_payload)
except Exception as exc:
self.set_status(str(exc))
diff --git a/src/mini_eq/window_utility.py b/src/mini_eq/window_utility.py
index 4060cb3..cb517e2 100644
--- a/src/mini_eq/window_utility.py
+++ b/src/mini_eq/window_utility.py
@@ -7,7 +7,12 @@
from gi.repository import Adw, Gtk, Pango
-from .window_utils import bind_label_to_control, set_accessible_description, set_accessible_label
+from .window_utils import (
+ bind_label_to_control,
+ make_ellipsizing_string_list_factory,
+ set_accessible_description,
+ set_accessible_label,
+)
class MiniEqWindowUtilityPaneMixin:
@@ -31,6 +36,8 @@ def make_preset_section(self) -> Gtk.Box:
self.preset_combo.set_hexpand(True)
self.preset_combo.add_css_class("toolbar-select")
+ self.preset_combo.set_factory(make_ellipsizing_string_list_factory(28))
+ self.preset_combo.set_list_factory(make_ellipsizing_string_list_factory(28))
set_accessible_label(self.preset_combo, "Preset")
preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
@@ -41,19 +48,48 @@ def make_preset_section(self) -> Gtk.Box:
preset_row.append(self.preset_combo)
preset_section.append(preset_row)
+ self.current_curve_state_label = Gtk.Label(xalign=0.0)
+ self.current_curve_state_label.set_hexpand(True)
+ self.current_curve_state_label.set_width_chars(1)
+ self.current_curve_state_label.set_max_width_chars(28)
+ self.current_curve_state_label.add_css_class("dim-label")
+ self.current_curve_state_label.set_ellipsize(Pango.EllipsizeMode.END)
+ set_accessible_label(self.current_curve_state_label, "Current Curve Source")
+
+ self.current_curve_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ self.current_curve_row.add_css_class("utility-row")
+ current_curve_label = Gtk.Label(label="Curve", xalign=0.0)
+ self.current_curve_row.append(current_curve_label)
+ self.current_curve_row.append(self.current_curve_state_label)
+ self.current_curve_row.set_visible(False)
+ preset_section.append(self.current_curve_row)
+
+ self.output_scope_state_label.set_hexpand(True)
+ self.output_scope_state_label.add_css_class("dim-label")
+ self.output_scope_state_label.add_css_class("output-scope-state-label")
+ self.output_scope_state_label.set_ellipsize(Pango.EllipsizeMode.END)
+ set_accessible_label(self.output_scope_state_label, "Auto Preset Scope")
+
+ output_scope_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ output_scope_row.add_css_class("utility-row")
+ output_scope_label = Gtk.Label(label="Scope", xalign=0.0)
+ output_scope_row.append(output_scope_label)
+ output_scope_row.append(self.output_scope_state_label)
+ preset_section.append(output_scope_row)
+
self.output_preset_state_label.set_hexpand(True)
self.output_preset_state_label.add_css_class("dim-label")
self.output_preset_state_label.set_ellipsize(Pango.EllipsizeMode.END)
- set_accessible_label(self.output_preset_state_label, "Output Preset Status")
+ set_accessible_label(self.output_preset_state_label, "Auto Preset Status")
output_preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
output_preset_row.add_css_class("utility-row")
- output_preset_label = Gtk.Label(label="Output Preset", xalign=0.0)
- bind_label_to_control(output_preset_label, self.output_preset_switch)
- output_preset_row.append(output_preset_label)
+ self.output_preset_scope_label = Gtk.Label(label="Auto Preset", xalign=0.0)
+ bind_label_to_control(self.output_preset_scope_label, self.output_preset_switch)
+ output_preset_row.append(self.output_preset_scope_label)
output_preset_row.append(self.output_preset_state_label)
self.output_preset_switch.set_valign(Gtk.Align.CENTER)
- set_accessible_label(self.output_preset_switch, "Output Preset")
+ set_accessible_label(self.output_preset_switch, "Auto Preset")
self.output_preset_switch.connect("state-set", self.on_output_preset_switch_changed)
output_preset_row.append(self.output_preset_switch)
preset_section.append(output_preset_row)
@@ -76,17 +112,19 @@ def make_preset_section(self) -> Gtk.Box:
self.preset_save_button.connect("clicked", self.on_preset_save_clicked)
self.preset_more_popover = Gtk.Popover()
- preset_more_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
- preset_more_box.set_margin_top(8)
- preset_more_box.set_margin_bottom(8)
- preset_more_box.set_margin_start(8)
- preset_more_box.set_margin_end(8)
-
- def append_preset_separator() -> None:
+ self.preset_more_popover.add_css_class("preset-more-popover")
+ preset_more_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ preset_more_box.add_css_class("preset-more-menu")
+ preset_more_box.set_margin_top(6)
+ preset_more_box.set_margin_bottom(6)
+ preset_more_box.set_margin_start(6)
+ preset_more_box.set_margin_end(6)
+
+ def append_preset_separator() -> Gtk.Separator:
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
- separator.set_margin_top(3)
- separator.set_margin_bottom(3)
+ separator.add_css_class("preset-menu-separator")
preset_more_box.append(separator)
+ return separator
def connect_preset_action(button: Gtk.Button, callback) -> None:
def on_clicked(clicked_button: Gtk.Button) -> None:
@@ -95,48 +133,69 @@ def on_clicked(clicked_button: Gtk.Button) -> None:
button.connect("clicked", on_clicked)
- self.preset_save_as_button = Gtk.Button(label="Save As…")
- self.preset_save_as_button.add_css_class("popover-action")
- connect_preset_action(self.preset_save_as_button, self.on_preset_save_as_clicked)
- preset_more_box.append(self.preset_save_as_button)
+ def make_preset_action(label: str, callback, *, destructive: bool = False) -> tuple[Gtk.Button, Gtk.Label]:
+ button = Gtk.Button()
+ button.set_can_shrink(True)
+ button.set_hexpand(True)
+ button.add_css_class("popover-action")
+ button.add_css_class("flat")
+ if destructive:
+ button.add_css_class("destructive-action")
+ action_label = Gtk.Label(label=label, xalign=0.0)
+ action_label.set_hexpand(True)
+ action_label.set_ellipsize(Pango.EllipsizeMode.END)
+ button.set_child(action_label)
+ connect_preset_action(button, callback)
+ preset_more_box.append(button)
+ return button, action_label
+
+ self.preset_save_as_button, self.preset_save_as_button_label = make_preset_action(
+ "Save As…",
+ self.on_preset_save_as_clicked,
+ )
- self.preset_revert_button = Gtk.Button(label="Revert")
- self.preset_revert_button.add_css_class("popover-action")
+ self.preset_revert_button, self.preset_revert_button_label = make_preset_action(
+ "Revert",
+ self.on_preset_revert_clicked,
+ )
self.preset_revert_button.set_tooltip_text("Loaded Preset")
- connect_preset_action(self.preset_revert_button, self.on_preset_revert_clicked)
- preset_more_box.append(self.preset_revert_button)
- append_preset_separator()
+ self.preset_reset_to_neutral_button, self.preset_reset_to_neutral_button_label = make_preset_action(
+ "Reset to Neutral",
+ self.on_preset_reset_to_neutral_clicked,
+ )
- self.default_preset_set_button = Gtk.Button(label="Use Selected as Default")
- self.default_preset_set_button.add_css_class("popover-action")
- connect_preset_action(self.default_preset_set_button, self.on_use_preset_as_default_clicked)
- preset_more_box.append(self.default_preset_set_button)
+ self.preset_default_separator = append_preset_separator()
- self.default_preset_clear_button = Gtk.Button(label="Clear Default")
- self.default_preset_clear_button.add_css_class("popover-action")
- connect_preset_action(self.default_preset_clear_button, self.on_clear_default_preset_clicked)
- preset_more_box.append(self.default_preset_clear_button)
+ self.default_preset_set_button, self.default_preset_set_button_label = make_preset_action(
+ "Set as Default",
+ self.on_use_preset_as_default_clicked,
+ )
- append_preset_separator()
+ self.default_preset_clear_button, self.default_preset_clear_button_label = make_preset_action(
+ "Clear Default",
+ self.on_clear_default_preset_clicked,
+ )
- self.preset_import_button = Gtk.Button(label="Import Preset…")
- self.preset_import_button.add_css_class("popover-action")
- connect_preset_action(self.preset_import_button, self.on_preset_import_clicked)
- preset_more_box.append(self.preset_import_button)
+ self.preset_file_separator = append_preset_separator()
- self.preset_export_button = Gtk.Button(label="Export Preset…")
- self.preset_export_button.add_css_class("popover-action")
- connect_preset_action(self.preset_export_button, self.on_preset_export_clicked)
- preset_more_box.append(self.preset_export_button)
+ self.preset_import_button, self.preset_import_button_label = make_preset_action(
+ "Import Preset…",
+ self.on_preset_import_clicked,
+ )
- append_preset_separator()
+ self.preset_export_button, self.preset_export_button_label = make_preset_action(
+ "Export Preset…",
+ self.on_preset_export_clicked,
+ )
- self.preset_delete_button = Gtk.Button(label="Delete")
- self.preset_delete_button.add_css_class("popover-action")
- self.preset_delete_button.add_css_class("destructive-action")
- connect_preset_action(self.preset_delete_button, self.on_preset_delete_clicked)
- preset_more_box.append(self.preset_delete_button)
+ self.preset_library_separator = append_preset_separator()
+
+ self.preset_delete_button, self.preset_delete_button_label = make_preset_action(
+ "Delete Preset",
+ self.on_preset_delete_clicked,
+ destructive=True,
+ )
self.preset_more_popover.set_child(preset_more_box)
preset_more_button = Gtk.MenuButton(label="More")
diff --git a/src/mini_eq/window_utils.py b/src/mini_eq/window_utils.py
index 4d32873..c75f8fb 100644
--- a/src/mini_eq/window_utils.py
+++ b/src/mini_eq/window_utils.py
@@ -36,3 +36,30 @@ def constrain_editor_label(label: Gtk.Label, width_chars: int) -> None:
label.set_max_width_chars(width_chars)
label.set_ellipsize(Pango.EllipsizeMode.END)
label.set_single_line_mode(True)
+
+
+def make_ellipsizing_string_list_factory(max_width_chars: int) -> Gtk.SignalListItemFactory:
+ factory = Gtk.SignalListItemFactory()
+
+ def setup(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) -> None:
+ label = Gtk.Label(xalign=0.0)
+ label.set_hexpand(True)
+ label.set_width_chars(1)
+ label.set_max_width_chars(max_width_chars)
+ label.set_ellipsize(Pango.EllipsizeMode.END)
+ label.set_single_line_mode(True)
+ list_item.set_child(label)
+
+ def bind(_factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem) -> None:
+ item = list_item.get_item()
+ child = list_item.get_child()
+ if not isinstance(child, Gtk.Label):
+ return
+
+ text = item.get_string() if isinstance(item, Gtk.StringObject) else ""
+ child.set_text(text)
+ child.set_tooltip_text(text)
+
+ factory.connect("setup", setup)
+ factory.connect("bind", bind)
+ return factory
diff --git a/tests/_mini_eq_imports.py b/tests/_mini_eq_imports.py
index 963fbb6..f997c9e 100644
--- a/tests/_mini_eq_imports.py
+++ b/tests/_mini_eq_imports.py
@@ -12,4 +12,5 @@ def import_mini_eq_module(name: str):
routing = import_mini_eq_module("routing")
instance = import_mini_eq_module("instance")
pipewire_backend = import_mini_eq_module("pipewire_backend")
+pipewire_routes = import_mini_eq_module("pipewire_routes")
pipewire_stream_router = import_mini_eq_module("pipewire_stream_router")
diff --git a/tests/test_mini_eq_atspi_widgets.py b/tests/test_mini_eq_atspi_widgets.py
index e57089c..107104a 100644
--- a/tests/test_mini_eq_atspi_widgets.py
+++ b/tests/test_mini_eq_atspi_widgets.py
@@ -435,6 +435,10 @@ def verify_dropdown_exposes_options(frame, *, combo_name, required_options):
raise AssertionError("Not Applied status is missing")
if find_accessible(frame, name="Off", role="status bar", showing=True) is None:
raise AssertionError("Monitor Off status is missing")
+ if find_accessible(frame, name="EQ output", role="combo box", showing=True) is None:
+ raise AssertionError("EQ output combo box is missing")
+ if find_accessible(frame, name="Preset", role="combo box", showing=True) is None:
+ raise AssertionError("Preset combo box is missing")
verify_dropdown_exposes_options(frame, combo_name="Type", required_options=("Notch", "Bell"))
diff --git a/tests/test_mini_eq_core.py b/tests/test_mini_eq_core.py
index 4dda04d..7d64b4d 100644
--- a/tests/test_mini_eq_core.py
+++ b/tests/test_mini_eq_core.py
@@ -95,6 +95,21 @@ def test_output_preset_links_roundtrip_and_sanitize_values(monkeypatch: pytest.M
}
+def test_output_preset_link_uses_first_matching_output_key(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
+
+ core.set_output_preset_link("alsa_output.speakers", "Speakers")
+ core.set_output_preset_link("pipewire-route:v1:device=card;route=headphones;route-device=8", "Headphones")
+
+ keys = (
+ "pipewire-route:v1:device=card;route=headphones;route-device=8",
+ "alsa_output.speakers",
+ )
+
+ assert core.get_output_preset_link(keys) == "Headphones"
+ assert core.get_output_preset_link(("missing", "alsa_output.speakers")) == "Speakers"
+
+
def test_output_preset_links_missing_file_returns_empty(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "missing.json")
@@ -134,6 +149,26 @@ def test_clear_output_preset_link_keeps_other_outputs(monkeypatch: pytest.Monkey
assert core.clear_output_preset_link("missing") is None
+def test_clear_output_preset_link_removes_all_matching_candidates(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
+ core.write_output_preset_links(
+ {
+ "alsa_output.speakers": "Speakers",
+ "pipewire-route:v1:device=card;route=headphones;route-device=8": "Headphones",
+ }
+ )
+
+ removed = core.clear_output_preset_link(
+ (
+ "pipewire-route:v1:device=card;route=headphones;route-device=8",
+ "alsa_output.speakers",
+ )
+ )
+
+ assert removed == "Headphones"
+ assert core.load_output_preset_links() == {}
+
+
def test_default_preset_roundtrip_preserves_output_links(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
core.set_output_preset_link("alsa_output.speakers", "Speakers")
diff --git a/tests/test_mini_eq_deps.py b/tests/test_mini_eq_deps.py
index 75b310a..8d09823 100644
--- a/tests/test_mini_eq_deps.py
+++ b/tests/test_mini_eq_deps.py
@@ -93,9 +93,11 @@ def fake_check(namespace: str, version: str, label: str, required: bool, hint: s
def test_pipewire_gobject_check_requires_current_library_version(monkeypatch) -> None:
fake_pwg = SimpleNamespace(
- get_library_version=lambda: "0.3.2",
+ get_library_version=lambda: "0.3.4",
Core=SimpleNamespace(set_pipewire_property=object()),
+ Device=SimpleNamespace(enum_all_params=object(), enum_params=object(), new=object()),
Param=SimpleNamespace(new_props_controls=object()),
+ RouteInfo=SimpleNamespace(new_from_param=object()),
Stream=SimpleNamespace(set_pipewire_property=object()),
)
@@ -114,14 +116,16 @@ 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.4" in check.detail
+ assert "older than required 0.3.5" in check.detail
def test_pipewire_gobject_check_requires_property_override_symbols(monkeypatch) -> None:
fake_pwg = SimpleNamespace(
- get_library_version=lambda: "0.3.4",
+ get_library_version=lambda: "0.3.5",
Core=SimpleNamespace(),
+ Device=SimpleNamespace(enum_all_params=object(), enum_params=object(), new=object()),
Param=SimpleNamespace(new_props_controls=object()),
+ RouteInfo=SimpleNamespace(new_from_param=object()),
Stream=SimpleNamespace(set_pipewire_property=object()),
)
diff --git a/tests/test_mini_eq_output_presets.py b/tests/test_mini_eq_output_presets.py
index 6485d5a..5e4652e 100644
--- a/tests/test_mini_eq_output_presets.py
+++ b/tests/test_mini_eq_output_presets.py
@@ -12,6 +12,8 @@ class FakeButton:
def __init__(self) -> None:
self.sensitive = True
self.tooltip = ""
+ self.visible = True
+ self.label = ""
def set_sensitive(self, sensitive: bool) -> None:
self.sensitive = sensitive
@@ -19,6 +21,12 @@ def set_sensitive(self, sensitive: bool) -> None:
def set_tooltip_text(self, text: str) -> None:
self.tooltip = text
+ def set_visible(self, visible: bool) -> None:
+ self.visible = visible
+
+ def set_label(self, text: str) -> None:
+ self.label = text
+
class FakeSwitch(FakeButton):
def __init__(self) -> None:
@@ -88,6 +96,30 @@ def set_sensitive(self, sensitive: bool) -> None:
self.sensitive = sensitive
+class FakeDeleteDialog:
+ def __init__(self, response: str = "delete") -> None:
+ self.response = response
+
+ def choose_finish(self, _result: object) -> str:
+ return self.response
+
+
+class FakeFile:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def get_path(self) -> str:
+ return self.path
+
+
+class FakeOpenDialog:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def open_finish(self, _result: object) -> FakeFile:
+ return FakeFile(self.path)
+
+
class OutputPresetWindow(window_presets.MiniEqWindowPresetMixin):
def __init__(self, controller) -> None:
self.controller = controller
@@ -107,14 +139,23 @@ def __init__(self, controller) -> None:
self.sync_count = 0
self.state_count = 0
self.presets_count = 0
+ self.replace_confirmations: list[SimpleNamespace] = []
self.preset_state_label = FakeLabel()
+ self.current_curve_state_label = FakeLabel()
+ self.current_curve_row = FakeButton()
+ self.output_scope_state_label = FakeLabel()
self.output_preset_state_label = FakeLabel()
+ self.output_preset_scope_label = FakeLabel()
self.preset_delete_button = FakeButton()
self.preset_export_button = FakeButton()
self.preset_import_button = FakeButton()
self.preset_revert_button = FakeButton()
+ self.preset_reset_to_neutral_button = FakeButton()
self.preset_save_button = FakeButton()
self.preset_save_as_button = FakeButton()
+ self.preset_default_separator = FakeButton()
+ self.preset_file_separator = FakeButton()
+ self.preset_library_separator = FakeButton()
self.default_preset_set_button = FakeButton()
self.default_preset_clear_button = FakeButton()
self.output_preset_switch = FakeSwitch()
@@ -136,6 +177,20 @@ def notify_control_state_changed(self) -> None:
def notify_control_presets_changed(self) -> None:
self.presets_count += 1
+ def confirm_preset_replacement(
+ self,
+ preset_name: str,
+ body: str,
+ replace_callback,
+ ) -> None:
+ self.replace_confirmations.append(
+ SimpleNamespace(
+ preset_name=preset_name,
+ body=body,
+ replace_callback=replace_callback,
+ )
+ )
+
def make_controller(output_sink: str = "alsa_output.headphones"):
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
@@ -156,6 +211,74 @@ def write_test_preset(name: str, gain_db: float) -> None:
core.write_mini_eq_preset_file(core.preset_path_for_name(name), payload)
+class FakeSink:
+ def __init__(
+ self,
+ *,
+ node_name: str,
+ node_description: str | None,
+ properties: dict[str, str],
+ ) -> None:
+ self.node_name = node_name
+ self.node_description = node_description
+ self.properties = properties
+
+ def property_value(self, key: str) -> str | None:
+ return self.properties.get(key)
+
+
+def test_output_dropdown_uses_device_first_labels() -> None:
+ fake_window = SimpleNamespace(controller=SimpleNamespace(output_sink="alsa_output.fallback"))
+ sink = FakeSink(
+ node_name="alsa_output.hdmi",
+ node_description="Audio interno Stereo digitale HDMI",
+ properties={"device.description": "Audio interno"},
+ )
+
+ assert window.MiniEqWindow.output_display_name(fake_window, sink) == "Audio interno"
+ assert window.MiniEqWindow.output_sink_detail_name(fake_window, sink, "Audio interno") == "Stereo digitale HDMI"
+
+
+def test_output_dropdown_detail_preserves_nested_parentheses() -> None:
+ fake_window = SimpleNamespace(controller=SimpleNamespace(output_sink="alsa_output.fallback"))
+ sink = FakeSink(
+ node_name="alsa_output.hdmi",
+ node_description="Audio interno Stereo digitale (HDMI)",
+ properties={"device.description": "Audio interno"},
+ )
+
+ assert window.MiniEqWindow.output_sink_detail_name(fake_window, sink, "Audio interno") == "Stereo digitale (HDMI)"
+
+
+def test_output_dropdown_disambiguates_duplicate_device_labels() -> None:
+ fake_window = SimpleNamespace(controller=SimpleNamespace(output_sink="alsa_output.fallback"))
+ fake_window.output_display_name = lambda sink: window.MiniEqWindow.output_display_name(fake_window, sink)
+ fake_window.output_sink_detail_name = lambda sink, label: window.MiniEqWindow.output_sink_detail_name(
+ fake_window,
+ sink,
+ label,
+ )
+ fake_window.format_sample_spec = lambda _sink: "48 kHz stereo"
+ fake_window.transport_label_for_sink = lambda _sink: "ALSA"
+ sinks = [
+ FakeSink(
+ node_name="alsa_output.hdmi",
+ node_description="Audio interno HDMI",
+ properties={"device.description": "Audio interno"},
+ ),
+ FakeSink(
+ node_name="alsa_output.speakers",
+ node_description="Audio interno Speakers",
+ properties={"device.description": "Audio interno"},
+ ),
+ ]
+
+ assert window.MiniEqWindow.build_output_sink_labels(fake_window, sinks) == [
+ "Audio interno (HDMI • 48 kHz stereo)",
+ "Audio interno (Speakers • 48 kHz stereo)",
+ ]
+
+
def test_revert_action_explains_missing_loaded_preset() -> None:
controller = make_controller()
controller.bands[0].gain_db = 2.0
@@ -164,8 +287,34 @@ def test_revert_action_explains_missing_loaded_preset() -> None:
test_window.refresh_preset_actions()
+ assert test_window.preset_revert_button.visible is False
assert test_window.preset_revert_button.sensitive is False
- assert test_window.preset_revert_button.tooltip == "Load or import a preset first"
+ assert test_window.preset_reset_to_neutral_button.visible is True
+ assert test_window.preset_save_button.label == "Save As…"
+ assert test_window.preset_save_as_button.visible is False
+
+
+def test_neutral_curve_uses_neutral_state_and_contextual_menu() -> None:
+ controller = make_controller()
+ test_window = OutputPresetWindow(controller)
+
+ test_window.update_preset_state()
+
+ assert test_window.preset_state_label.text == "Neutral"
+ assert test_window.preset_state_label.tooltip == "Current curve is neutral"
+ assert test_window.preset_state_label.classes == {"preset-state-neutral"}
+ assert test_window.current_curve_row.visible is False
+ assert test_window.current_curve_state_label.text == ""
+ assert test_window.preset_save_button.label == "Save As…"
+ assert test_window.preset_save_as_button.visible is False
+ assert test_window.preset_revert_button.visible is False
+ assert test_window.preset_reset_to_neutral_button.visible is False
+ assert test_window.default_preset_set_button.visible is False
+ assert test_window.default_preset_clear_button.visible is False
+ assert test_window.preset_default_separator.visible is False
+ assert test_window.preset_file_separator.visible is False
+ assert test_window.preset_delete_button.visible is False
+ assert test_window.preset_export_button.label == "Export Current Curve…"
def test_revert_action_tracks_initial_neutral_baseline() -> None:
@@ -174,14 +323,46 @@ def test_revert_action_tracks_initial_neutral_baseline() -> None:
test_window.refresh_preset_actions()
+ assert test_window.preset_revert_button.visible is False
assert test_window.preset_revert_button.sensitive is False
- assert test_window.preset_revert_button.tooltip == "No curve changes to revert"
+ assert test_window.preset_reset_to_neutral_button.visible is False
controller.bands[0].gain_db = 2.0
test_window.refresh_preset_actions()
- assert test_window.preset_revert_button.sensitive is True
- assert test_window.preset_revert_button.tooltip == "Revert to Neutral"
+ assert test_window.preset_revert_button.visible is False
+ assert test_window.preset_revert_button.sensitive is False
+ assert test_window.preset_reset_to_neutral_button.visible is True
+ assert test_window.preset_reset_to_neutral_button.sensitive is True
+
+
+def test_reset_to_neutral_action_tracks_current_curve() -> None:
+ controller = make_controller()
+ test_window = OutputPresetWindow(controller)
+
+ test_window.refresh_preset_actions()
+
+ assert test_window.preset_reset_to_neutral_button.visible is False
+ assert test_window.preset_reset_to_neutral_button.sensitive is False
+ assert test_window.preset_reset_to_neutral_button.tooltip == "Curve is already neutral"
+
+ controller.bands[0].gain_db = 2.0
+ test_window.refresh_preset_actions()
+
+ assert test_window.preset_revert_button.visible is False
+ assert test_window.preset_revert_button.sensitive is False
+ assert test_window.preset_reset_to_neutral_button.visible is True
+ assert test_window.preset_reset_to_neutral_button.sensitive is True
+ assert test_window.preset_reset_to_neutral_button.tooltip == "Reset all bands and preamp to neutral"
+
+ test_window.on_preset_reset_to_neutral_clicked(FakeButton())
+
+ assert test_window.current_preset_name is None
+ assert controller.state_signature() == controller.default_state_signature()
+ assert test_window.visible_band_count == core.DEFAULT_ACTIVE_BANDS
+ assert test_window.output_preset_auto_applied is False
+ assert test_window.output_preset_curve_auto_loaded is False
+ assert test_window.statuses[-1] == "Reset to Neutral"
def test_revert_action_updates_for_named_preset_changes() -> None:
@@ -192,14 +373,22 @@ def test_revert_action_updates_for_named_preset_changes() -> None:
test_window.refresh_preset_actions()
+ assert test_window.preset_save_button.label == "Save"
+ assert test_window.preset_save_as_button.visible is True
+ assert test_window.preset_revert_button.visible is False
assert test_window.preset_revert_button.sensitive is False
- assert test_window.preset_revert_button.tooltip == "No curve changes to revert"
+ assert test_window.default_preset_set_button.visible is True
+ assert test_window.preset_export_button.label == "Export Preset…"
+ assert test_window.preset_delete_button.visible is True
controller.bands[0].gain_db = 2.0
test_window.refresh_preset_actions()
+ assert test_window.preset_revert_button.visible is True
assert test_window.preset_revert_button.sensitive is True
assert test_window.preset_revert_button.tooltip == "Revert to Headphones"
+ assert test_window.preset_revert_button.label == "Revert to Headphones"
+ assert test_window.preset_reset_to_neutral_button.visible is True
def test_revert_action_tracks_unsaved_import_baseline() -> None:
@@ -210,6 +399,7 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None:
test_window.refresh_preset_actions()
+ assert test_window.preset_revert_button.visible is False
assert test_window.preset_revert_button.sensitive is False
assert test_window.preset_revert_button.tooltip == "No curve changes to revert"
@@ -217,6 +407,7 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None:
test_window.update_preset_state()
assert test_window.preset_state_label.text == "Modified"
+ assert test_window.preset_revert_button.visible is True
assert test_window.preset_revert_button.sensitive is True
assert test_window.preset_revert_button.tooltip == "Revert to Imported APO Preset"
@@ -229,6 +420,110 @@ def test_revert_action_tracks_unsaved_import_baseline() -> None:
assert test_window.statuses[-1] == "Reverted to Imported APO Preset"
+def test_unsaved_apo_import_is_shown_as_current_curve(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ controller = make_controller()
+ controller.bands[0].gain_db = 2.0
+ test_window = OutputPresetWindow(controller)
+ test_window.set_curve_revert_baseline("Imported APO: HD 650")
+
+ test_window.refresh_preset_list()
+
+ assert test_window.preset_model.items == []
+ assert test_window.preset_combo.selected == window_presets.Gtk.INVALID_LIST_POSITION
+ assert test_window.current_curve_row.visible is True
+ assert test_window.current_curve_state_label.text == "Imported APO: HD 650"
+ assert test_window.current_curve_state_label.tooltip == (
+ "Imported APO: HD 650\nUse Save As to add it to the preset library."
+ )
+ assert test_window.suggested_save_as_name() == "HD 650"
+
+ test_window.on_preset_selected(test_window.preset_combo, None)
+
+ assert test_window.current_preset_name is None
+
+
+def test_saved_preset_selection_ignores_current_curve_label(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ write_test_preset("Headphones", 4.0)
+ controller = make_controller()
+ controller.bands[0].gain_db = 2.0
+ test_window = OutputPresetWindow(controller)
+ test_window.set_curve_revert_baseline("Imported APO: HD 650")
+
+ test_window.refresh_preset_list()
+
+ assert test_window.preset_model.items == ["Headphones"]
+ assert test_window.current_curve_state_label.text == "Imported APO: HD 650"
+
+ test_window.preset_combo.selected = 0
+ test_window.on_preset_selected(test_window.preset_combo, None)
+
+ assert test_window.current_preset_name == "Headphones"
+ assert controller.bands[0].gain_db == 4.0
+
+
+def test_save_as_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ write_test_preset("Headphones", 4.0)
+ controller = make_controller()
+ controller.bands[0].gain_db = 2.0
+ test_window = OutputPresetWindow(controller)
+
+ test_window.save_current_state_to_preset_as("Headphones")
+
+ assert len(test_window.replace_confirmations) == 1
+ confirmation = test_window.replace_confirmations[0]
+ assert confirmation.preset_name == "Headphones"
+ assert confirmation.body == "Headphones already exists. Replace it with the current curve?"
+ assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 4.0
+
+ confirmation.replace_callback()
+
+ assert test_window.current_preset_name == "Headphones"
+ assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 2.0
+
+
+def test_save_as_current_preset_overwrites_without_replace_confirmation(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ write_test_preset("Headphones", 4.0)
+ controller = make_controller()
+ test_window = OutputPresetWindow(controller)
+ test_window.load_library_preset("Headphones")
+ controller.bands[0].gain_db = 2.0
+
+ test_window.save_current_state_to_preset_as("Headphones")
+
+ assert test_window.replace_confirmations == []
+ assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 2.0
+
+
+def test_importing_existing_preset_requires_replace_confirmation(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ write_test_preset("Headphones", 4.0)
+ import_controller = make_controller()
+ import_controller.bands[0].gain_db = 6.0
+ import_path = tmp_path / "headphones.json"
+ core.write_mini_eq_preset_file(import_path, import_controller.build_preset_payload("Headphones"))
+ controller = make_controller()
+ test_window = OutputPresetWindow(controller)
+
+ test_window.on_preset_import_done(FakeOpenDialog(str(import_path)), None)
+
+ assert len(test_window.replace_confirmations) == 1
+ confirmation = test_window.replace_confirmations[0]
+ assert confirmation.preset_name == "Headphones"
+ assert confirmation.body == "Headphones already exists. Replace it with the imported preset?"
+ assert controller.bands[0].gain_db == 0.0
+ assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 4.0
+
+ confirmation.replace_callback()
+
+ assert test_window.current_preset_name == "Headphones"
+ assert controller.bands[0].gain_db == 6.0
+ assert core.load_mini_eq_preset_file(core.preset_path_for_name("Headphones"))["bands"][0]["gain_db"] == 6.0
+
+
def test_initial_output_preset_auto_loads_linked_preset(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
@@ -243,7 +538,8 @@ def test_initial_output_preset_auto_loads_linked_preset(monkeypatch, tmp_path) -
assert test_window.output_preset_auto_applied is True
assert test_window.output_preset_curve_auto_loaded is True
assert controller.bands[0].gain_db == 2.5
- assert test_window.output_preset_state_label.text == ""
+ assert test_window.output_preset_state_label.text == "Linked to EQ output"
+ assert test_window.output_scope_state_label.text == "Output-wide"
assert test_window.output_preset_switch.active is True
@@ -282,7 +578,7 @@ def test_output_change_without_link_resets_previous_auto_preset(monkeypatch, tmp
assert test_window.output_preset_auto_applied is False
assert test_window.output_preset_curve_auto_loaded is False
assert test_window.sync_count == 1
- assert test_window.statuses[-1] == "Reset to Neutral"
+ assert test_window.statuses[-1] == "No Auto Preset: Reset to Neutral"
def test_output_change_without_link_applies_default_preset(monkeypatch, tmp_path) -> None:
@@ -302,7 +598,7 @@ def test_output_change_without_link_applies_default_preset(monkeypatch, tmp_path
assert controller.bands[0].gain_db == -1.5
assert test_window.output_preset_auto_applied is False
assert test_window.output_preset_curve_auto_loaded is True
- assert test_window.statuses[-1] == "Applied Default Preset"
+ assert test_window.statuses[-1] == "No Auto Preset: Applied Default Preset"
def test_default_preset_loads_for_initial_unlinked_output(monkeypatch, tmp_path) -> None:
@@ -317,7 +613,7 @@ def test_default_preset_loads_for_initial_unlinked_output(monkeypatch, tmp_path)
assert test_window.current_preset_name == "Neutral"
assert controller.bands[0].gain_db == -1.5
- assert test_window.statuses[-1] == "Applied Default Preset"
+ assert test_window.statuses[-1] == "No Auto Preset: Applied Default Preset"
def test_output_change_without_link_keeps_manual_preset(monkeypatch, tmp_path) -> None:
@@ -336,7 +632,7 @@ def test_output_change_without_link_keeps_manual_preset(monkeypatch, tmp_path) -
assert test_window.current_preset_name == "Manual"
assert controller.bands[0].gain_db == 2.5
assert test_window.sync_count == 0
- assert test_window.statuses[-1] == "Kept Current Curve"
+ assert test_window.statuses[-1] == "No Auto Preset: Curve Unchanged"
def test_output_change_without_link_keeps_unsaved_auto_preset_edits(monkeypatch, tmp_path) -> None:
@@ -360,7 +656,7 @@ def test_output_change_without_link_keeps_unsaved_auto_preset_edits(monkeypatch,
assert test_window.current_preset_name == "Headphones"
assert controller.bands[0].gain_db == 3.5
assert test_window.sync_count == 0
- assert test_window.statuses[-1] == "Kept Unsaved Changes"
+ assert test_window.statuses[-1] == "No Auto Preset: Kept Unsaved Changes"
def test_deleted_output_preset_link_is_left_clearable(monkeypatch, tmp_path) -> None:
@@ -373,11 +669,11 @@ def test_deleted_output_preset_link_is_left_clearable(monkeypatch, tmp_path) ->
assert test_window.apply_output_preset_for_current_output() is True
assert core.get_output_preset_link("alsa_output.headphones") == "Missing"
- assert test_window.output_preset_state_label.text == "Linked"
+ assert test_window.output_preset_state_label.text == "Linked to EQ output"
assert test_window.output_preset_state_label.visible is True
assert test_window.output_preset_switch.active is True
assert test_window.output_preset_switch.sensitive is True
- assert test_window.statuses[-1] == "Output Preset Unavailable: Missing"
+ assert test_window.statuses[-1] == "Auto Preset Unavailable: Missing"
def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_path) -> None:
@@ -390,7 +686,8 @@ def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_pa
test_window.on_use_preset_for_output_clicked(FakeButton())
assert core.get_output_preset_link("alsa_output.headphones") == "Headphones"
- assert test_window.output_preset_state_label.text == ""
+ assert test_window.output_preset_state_label.text == "Linked to EQ output"
+ assert test_window.output_preset_scope_label.text == "Auto Preset"
assert test_window.output_preset_switch.active is True
test_window.output_preset_curve_auto_loaded = True
@@ -402,6 +699,79 @@ def test_output_preset_actions_link_and_clear_current_output(monkeypatch, tmp_pa
assert test_window.output_preset_switch.active is False
+def test_output_preset_actions_use_route_key_when_available(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
+ write_test_preset("Headphones", 2.0)
+ route_key = "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=8"
+ controller = make_controller()
+ route = SimpleNamespace(
+ description="Headphones",
+ name="analog-output-headphones",
+ output_preset_key=route_key,
+ )
+ target = SimpleNamespace(
+ output_key=controller.output_sink,
+ route=route,
+ keys=(route_key, controller.output_sink),
+ link_key=route_key,
+ has_route_key=True,
+ )
+ controller.output_preset_target = lambda: target
+ controller.output_preset_keys = lambda: (route_key, controller.output_sink)
+ controller.output_preset_link_key = lambda: route_key
+ test_window = OutputPresetWindow(controller)
+ test_window.load_library_preset("Headphones")
+
+ test_window.on_use_preset_for_output_clicked(FakeButton())
+
+ assert core.get_output_preset_link(route_key) == "Headphones"
+ assert core.get_output_preset_link(controller.output_sink) is None
+ assert test_window.output_scope_state_label.text == "Headphones"
+ assert test_window.output_preset_scope_label.text == "Auto Preset"
+ assert test_window.statuses[-1] == "Linked Auto Preset to Port: Headphones"
+
+ core.set_output_preset_link(controller.output_sink, "Legacy Output")
+ test_window.on_clear_output_preset_link_clicked(FakeButton())
+
+ assert core.get_output_preset_link(route_key) is None
+ assert core.get_output_preset_link(controller.output_sink) is None
+ assert test_window.statuses[-1] == "Cleared Auto Preset from Port: Headphones"
+
+
+def test_deleting_only_loaded_preset_keeps_curve_and_allows_neutral_reset(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
+ monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
+ write_test_preset("Headphones", 2.5)
+ controller = make_controller()
+ test_window = OutputPresetWindow(controller)
+ test_window.refresh_preset_list()
+ test_window.load_library_preset("Headphones")
+
+ test_window.on_preset_delete_dialog_done(FakeDeleteDialog(), None, "Headphones")
+
+ assert core.list_preset_names() == []
+ assert test_window.preset_names == []
+ assert test_window.current_preset_name is None
+ assert controller.bands[0].gain_db == 2.5
+ assert test_window.preset_state_label.text == "Unsaved"
+ assert test_window.current_curve_row.visible is True
+ assert test_window.current_curve_state_label.text == "Unsaved copy: Headphones"
+ assert test_window.suggested_save_as_name() == "Headphones"
+ assert test_window.preset_revert_button.visible is False
+ assert test_window.preset_revert_button.sensitive is False
+ assert test_window.preset_revert_button.tooltip == "No curve changes to revert"
+ assert test_window.preset_reset_to_neutral_button.visible is True
+ assert test_window.preset_reset_to_neutral_button.sensitive is True
+ assert test_window.statuses[-1] == "Deleted Preset: Headphones; Current Curve Kept"
+
+ test_window.on_preset_reset_to_neutral_clicked(FakeButton())
+
+ assert controller.state_signature() == controller.default_state_signature()
+ assert test_window.current_preset_name is None
+ assert test_window.statuses[-1] == "Reset to Neutral"
+
+
def test_default_preset_actions_set_and_clear(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
@@ -442,7 +812,7 @@ def test_output_preset_link_state_shows_different_selected_preset(monkeypatch, t
assert test_window.output_preset_state_label.text == "Different"
assert test_window.output_preset_state_label.visible is True
assert test_window.output_preset_switch.active is True
- assert test_window.output_preset_switch.tooltip == "Clear Output Preset"
+ assert test_window.output_preset_switch.tooltip == "Clear auto preset for EQ output"
def test_output_preset_link_toggle_clears_different_selected_preset(monkeypatch, tmp_path) -> None:
@@ -484,7 +854,7 @@ def test_output_preset_link_toggle_links_and_clears_current_output(monkeypatch,
assert test_window.output_preset_switch.state is False
-def test_manual_output_change_runs_output_preset_auto_apply() -> None:
+def test_manual_output_change_defers_output_preset_handling_to_pipewire_refresh() -> None:
calls: list[object] = []
fake_window = SimpleNamespace(
updating_output_combo=False,
@@ -492,8 +862,8 @@ def test_manual_output_change_runs_output_preset_auto_apply() -> None:
output_preset_curve_auto_loaded=True,
output_sink_names=[None, "alsa_output.headphones"],
controller=SimpleNamespace(change_output_sink=lambda sink: calls.append(("change", sink))),
- refresh_output_sinks=lambda *, auto_apply_output_preset=True: calls.append(
- ("refresh", auto_apply_output_preset)
+ refresh_output_sinks=lambda *, handle_observed_output_change=True: calls.append(
+ ("refresh", handle_observed_output_change)
),
apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)) or True,
set_status=lambda message: calls.append(("status", message)),
@@ -504,35 +874,33 @@ def test_manual_output_change_runs_output_preset_auto_apply() -> None:
assert calls == [
("change", "alsa_output.headphones"),
("refresh", False),
- ("auto", {"reset_auto_preset_without_link": True, "announce_no_output_preset": True}),
]
-def test_manual_output_change_uses_auto_loaded_source_for_reset_decision() -> None:
+def test_manual_output_change_to_follow_default_defers_output_preset_handling() -> None:
calls: list[object] = []
fake_window = SimpleNamespace(
updating_output_combo=False,
output_preset_auto_applied=True,
output_preset_curve_auto_loaded=False,
output_sink_names=[None, "alsa_output.headphones"],
- controller=SimpleNamespace(change_output_sink=lambda sink: calls.append(("change", sink))),
- refresh_output_sinks=lambda *, auto_apply_output_preset=True: calls.append(
- ("refresh", auto_apply_output_preset)
+ controller=SimpleNamespace(follow_system_default_output=lambda: calls.append("follow")),
+ refresh_output_sinks=lambda *, handle_observed_output_change=True: calls.append(
+ ("refresh", handle_observed_output_change)
),
apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)) or True,
set_status=lambda message: calls.append(("status", message)),
)
- window.MiniEqWindow.on_output_changed(fake_window, FakeCombo(selected=1), None)
+ window.MiniEqWindow.on_output_changed(fake_window, FakeCombo(selected=0), None)
assert calls == [
- ("change", "alsa_output.headphones"),
+ "follow",
("refresh", False),
- ("auto", {"reset_auto_preset_without_link": False, "announce_no_output_preset": True}),
]
-def test_system_default_output_change_runs_output_preset_auto_apply() -> None:
+def test_pipewire_observed_output_change_runs_output_preset_handling() -> None:
calls: list[object] = []
fake_window = SimpleNamespace(
ui_shutting_down=False,
@@ -569,3 +937,51 @@ def test_system_default_output_change_runs_output_preset_auto_apply() -> None:
("auto", {"reset_auto_preset_without_link": True, "announce_no_output_preset": True}),
]
assert fake_window.last_output_preset_sink_name == "alsa_output.usb"
+
+
+def test_manual_output_refresh_updates_selector_without_handling_observed_output_change() -> None:
+ calls: list[object] = []
+ fake_window = SimpleNamespace(
+ ui_shutting_down=False,
+ controller=SimpleNamespace(
+ output_sink="alsa_output.usb",
+ follow_default_output=False,
+ get_default_output_sink_name=lambda: "alsa_output.usb",
+ get_sink=lambda _sink_name: None,
+ ),
+ last_output_preset_sink_name="alsa_output.speakers",
+ output_preset_auto_applied=True,
+ output_preset_curve_auto_loaded=False,
+ post_present_ready=True,
+ list_visible_output_sinks=lambda: [],
+ build_output_sink_labels=lambda _sinks: [],
+ follow_default_output_label=lambda: "Follow system output",
+ output_sink_names=[],
+ output_sink_labels=[],
+ output_sink_model=FakeModel(),
+ output_combo=FakeCombo(),
+ updating_output_combo=False,
+ update_preset_state=lambda: calls.append("preset-state"),
+ update_info_label=lambda: calls.append("info"),
+ update_status_summary=lambda: calls.append("summary"),
+ apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)),
+ )
+
+ window.MiniEqWindow.refresh_output_sinks(
+ fake_window,
+ handle_observed_output_change=False,
+ )
+
+ assert calls == ["preset-state", "info", "summary"]
+ assert fake_window.last_output_preset_sink_name == "alsa_output.speakers"
+
+ calls.clear()
+ window.MiniEqWindow.refresh_output_sinks(fake_window)
+
+ assert calls == [
+ "preset-state",
+ "info",
+ "summary",
+ ("auto", {"reset_auto_preset_without_link": False, "announce_no_output_preset": True}),
+ ]
+ assert fake_window.last_output_preset_sink_name == "alsa_output.usb"
diff --git a/tests/test_mini_eq_pipewire_backend.py b/tests/test_mini_eq_pipewire_backend.py
index 61bf250..a27cfb5 100644
--- a/tests/test_mini_eq_pipewire_backend.py
+++ b/tests/test_mini_eq_pipewire_backend.py
@@ -1,8 +1,11 @@
from __future__ import annotations
+from types import SimpleNamespace
+
import pytest
from tests._mini_eq_imports import pipewire_backend as pw_backend
+from tests._mini_eq_imports import pipewire_routes as pw_routes
class FakeCore:
@@ -185,6 +188,26 @@ class FakePwg:
Param = FakePwgParam
+class FakeDeviceApi:
+ @staticmethod
+ def enum_params():
+ return None
+
+ @staticmethod
+ def new():
+ return None
+
+ @staticmethod
+ def subscribe_params():
+ return None
+
+
+class FakeRouteInfoApi:
+ @staticmethod
+ def new_from_param(_param):
+ return None
+
+
def test_parse_metadata_node_name_reads_wireplumber_json_name() -> None:
assert pw_backend.parse_metadata_node_name('{"name":"alsa_output.test"}') == "alsa_output.test"
@@ -270,6 +293,79 @@ def test_node_classification_and_display_name() -> None:
assert stream.display_name == "spotify"
+def test_node_from_global_copies_device_route_properties() -> None:
+ backend = pw_backend.PipeWireBackend()
+
+ class FakeGlobal(FakePropertyProxy):
+ def get_id(self) -> int:
+ return 39
+
+ node = backend._node_from_global(
+ FakeGlobal(
+ FakeGlobalProperties(
+ [
+ FakePropertyItem("object.serial", "67"),
+ FakePropertyItem("media.class", pw_backend.AUDIO_SINK),
+ FakePropertyItem("node.name", "alsa_output.test"),
+ FakePropertyItem("device.id", "72"),
+ FakePropertyItem("card.profile.device", "8"),
+ ]
+ )
+ )
+ )
+
+ assert node.device_id == 72
+ assert node.card_profile_device == 8
+
+
+def test_node_from_global_enriches_device_label_properties() -> None:
+ backend = pw_backend.PipeWireBackend()
+
+ class FakeNodeGlobal(FakePropertyProxy):
+ def get_id(self) -> int:
+ return 39
+
+ class FakeDeviceGlobal(FakePropertyProxy):
+ def is_device(self) -> bool:
+ return True
+
+ class FakeRegistry:
+ def __init__(self) -> None:
+ self.device = FakeDeviceGlobal(
+ FakeGlobalProperties(
+ [
+ FakePropertyItem("device.name", "alsa_card.pci-0000_00_1f.3"),
+ FakePropertyItem("device.description", "Audio interno"),
+ FakePropertyItem("device.nick", "HDA Intel PCH"),
+ ]
+ )
+ )
+
+ def lookup_global(self, bound_id: int) -> FakeDeviceGlobal | None:
+ return self.device if bound_id == 72 else None
+
+ backend._registry = FakeRegistry()
+ node = backend._node_from_global(
+ FakeNodeGlobal(
+ FakeGlobalProperties(
+ [
+ FakePropertyItem("object.serial", "67"),
+ FakePropertyItem("media.class", pw_backend.AUDIO_SINK),
+ FakePropertyItem("node.name", "alsa_output.hdmi"),
+ FakePropertyItem("node.description", "Audio interno Stereo digitale (HDMI)"),
+ FakePropertyItem("device.id", "72"),
+ FakePropertyItem("card.profile.device", "8"),
+ ]
+ )
+ )
+ )
+
+ assert node.node_description == "Audio interno Stereo digitale (HDMI)"
+ assert node.property_value("device.description") == "Audio interno"
+ assert node.property_value("device.nick") == "HDA Intel PCH"
+ assert node.property_value("device.name") == "alsa_card.pci-0000_00_1f.3"
+
+
def test_new_core_uses_pipewire_gobject_core_constructor() -> None:
FakeCore.calls = 0
@@ -325,6 +421,247 @@ def test_move_stream_to_target_sets_stream_target_without_metadata_readback() ->
assert calls == [(126, 39, "67")]
+def test_output_preset_keys_prefer_matching_active_route(monkeypatch: pytest.MonkeyPatch) -> None:
+ backend = pw_backend.PipeWireBackend()
+ backend._Pwg = SimpleNamespace(Device=FakeDeviceApi, RouteInfo=FakeRouteInfoApi)
+ sink = pw_backend.PipeWireNode(
+ bound_id=39,
+ object_serial="67",
+ media_class=pw_backend.AUDIO_SINK,
+ node_name="alsa_output.test",
+ node_description="Test Sink",
+ application_name=None,
+ node_dont_move=False,
+ device_id=72,
+ card_profile_device=8,
+ )
+ line_out = pw_routes.PipeWireOutputRoute(
+ device_bound_id=72,
+ device_name="alsa_card.test",
+ index=0,
+ route_device=7,
+ profile=0,
+ priority=100,
+ direction="Output",
+ name="analog-output-lineout",
+ description="Line Out",
+ availability="yes",
+ )
+ headphones = pw_routes.PipeWireOutputRoute(
+ device_bound_id=72,
+ device_name="alsa_card.test",
+ index=1,
+ route_device=8,
+ profile=0,
+ priority=200,
+ direction="Output",
+ name="analog-output-headphones",
+ description="Headphones",
+ availability="yes",
+ )
+
+ monkeypatch.setattr(backend, "audio_sink_by_name", lambda _name: sink)
+ monkeypatch.setattr(backend, "_device_proxy_by_bound_id", lambda _bound_id: object())
+ monkeypatch.setattr(backend, "_enumerate_device_routes", lambda _device, _bound_id: [line_out, headphones])
+
+ assert backend.output_preset_keys_for_sink_name("alsa_output.test") == (
+ "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=8",
+ "alsa_output.test",
+ )
+
+
+def test_output_preset_keys_fall_back_to_sink_name_without_route_api(monkeypatch: pytest.MonkeyPatch) -> None:
+ backend = pw_backend.PipeWireBackend()
+ backend._Pwg = SimpleNamespace()
+ sink = pw_backend.PipeWireNode(
+ bound_id=39,
+ object_serial="67",
+ media_class=pw_backend.AUDIO_SINK,
+ node_name="alsa_output.test",
+ node_description="Test Sink",
+ application_name=None,
+ node_dont_move=False,
+ device_id=72,
+ card_profile_device=8,
+ )
+
+ monkeypatch.setattr(backend, "audio_sink_by_name", lambda _name: sink)
+
+ assert backend.output_preset_keys_for_sink_name("alsa_output.test") == ("alsa_output.test",)
+
+
+def test_enumerate_device_routes_ignores_enum_route_params(monkeypatch: pytest.MonkeyPatch) -> None:
+ class FakeParam:
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ def get_seq(self) -> int:
+ return 12
+
+ def dup_name(self) -> str:
+ return self.name
+
+ class FakeParamInfo:
+ def get_id(self) -> int:
+ return 13
+
+ def dup_name(self) -> str:
+ return "Route"
+
+ class FakeModel:
+ def __init__(self, items: list[object]) -> None:
+ self.items = items
+
+ def get_n_items(self) -> int:
+ return len(self.items)
+
+ def get_item(self, index: int) -> object:
+ return self.items[index]
+
+ class FakeDevice:
+ def __init__(self) -> None:
+ self.params = FakeModel([FakeParam("EnumRoute"), FakeParam("Route")])
+ self.param_infos = FakeModel([FakeParamInfo()])
+ self.enum_calls: list[tuple[int, int, int]] = []
+
+ def enum_params(self, param_id: int, start: int, num: int) -> int:
+ self.enum_calls.append((param_id, start, num))
+ return 12
+
+ def get_params(self) -> FakeModel:
+ return self.params
+
+ def get_param_infos(self) -> FakeModel:
+ return self.param_infos
+
+ class FakeRouteInfo:
+ def get_index(self) -> int:
+ return 1
+
+ def get_device(self) -> int:
+ return 8
+
+ def get_profile(self) -> int:
+ return 0
+
+ def get_priority(self) -> int:
+ return 200
+
+ def dup_direction(self) -> str:
+ return "output"
+
+ def dup_name(self) -> str:
+ return "analog-output-headphones"
+
+ def dup_description(self) -> str:
+ return "Headphones"
+
+ def dup_availability(self) -> str:
+ return "yes"
+
+ def get_info(self) -> dict[str, str]:
+ return {}
+
+ created_from: list[str] = []
+
+ def new_from_param(param: FakeParam) -> FakeRouteInfo:
+ created_from.append(param.name)
+ return FakeRouteInfo()
+
+ backend = pw_backend.PipeWireBackend()
+ backend._Pwg = SimpleNamespace(RouteInfo=SimpleNamespace(new_from_param=new_from_param))
+ monkeypatch.setattr(backend, "_device_name_by_bound_id", lambda _bound_id: "alsa_card.test")
+
+ device = FakeDevice()
+ routes = backend._enumerate_device_routes(device, 72)
+
+ assert device.enum_calls == [(13, 0, 0)]
+ assert created_from == ["Route"]
+ assert [route.name for route in routes] == ["analog-output-headphones"]
+ assert backend._device_route_refreshing_bound_ids == set()
+
+
+def test_connect_device_route_changed_subscribes_to_route_param(monkeypatch: pytest.MonkeyPatch) -> None:
+ class FakeParam:
+ def __init__(self, param_id: int) -> None:
+ self.param_id = param_id
+
+ def get_id(self) -> int:
+ return self.param_id
+
+ class FakeParamInfo:
+ def get_id(self) -> int:
+ return 13
+
+ def dup_name(self) -> str:
+ return "Route"
+
+ class FakeModel:
+ def __init__(self, items: list[object]) -> None:
+ self.items = items
+
+ def get_n_items(self) -> int:
+ return len(self.items)
+
+ def get_item(self, index: int) -> object:
+ return self.items[index]
+
+ class FakeDevice:
+ def __init__(self) -> None:
+ self.param_infos = FakeModel([FakeParamInfo()])
+ self.subscriptions: list[FakeVariant] = []
+ self.disconnected: list[int] = []
+ self.param_callback = None
+
+ def get_param_infos(self) -> FakeModel:
+ return self.param_infos
+
+ def subscribe_params(self, ids: FakeVariant) -> None:
+ self.subscriptions.append(ids)
+
+ def disconnect(self, handler_id: int) -> None:
+ self.disconnected.append(handler_id)
+
+ def emit_param(self, param_id: int) -> None:
+ assert self.param_callback is not None
+ self.param_callback(self, FakeParam(param_id))
+
+ class FakeGObjectObject:
+ @staticmethod
+ def connect(device: FakeDevice, signal_name: str, callback) -> int:
+ assert signal_name == "param"
+ device.param_callback = callback
+ return 77
+
+ backend = pw_backend.PipeWireBackend()
+ device = FakeDevice()
+ backend._Pwg = SimpleNamespace(Device=FakeDeviceApi, RouteInfo=FakeRouteInfoApi)
+ backend._GLib = FakeGLib
+ backend._GObject = SimpleNamespace(Object=FakeGObjectObject)
+ backend._ensure_connected = lambda: None
+ monkeypatch.setattr(backend, "_device_proxy_by_bound_id", lambda _bound_id: device)
+ calls: list[str] = []
+
+ handler_id = backend.connect_device_route_changed(72, lambda: calls.append("route"))
+
+ assert handler_id == 77
+ assert [(variant.signature, variant.value) for variant in device.subscriptions] == [("au", [13])]
+
+ device.emit_param(12)
+ assert calls == []
+
+ device.emit_param(13)
+ assert calls == ["route"]
+
+ backend._device_route_refreshing_bound_ids.add(72)
+ device.emit_param(13)
+ assert calls == ["route"]
+
+ backend.disconnect_device_handler(handler_id)
+ assert [(variant.signature, variant.value) for variant in device.subscriptions] == [("au", [13]), ("au", [])]
+ assert device.disconnected == [77]
+
+
def test_move_named_output_stream_to_target_uses_matching_stream() -> None:
backend = pw_backend.PipeWireBackend()
stream = pw_backend.PipeWireNode(
diff --git a/tests/test_mini_eq_pipewire_routes.py b/tests/test_mini_eq_pipewire_routes.py
new file mode 100644
index 0000000..bb77706
--- /dev/null
+++ b/tests/test_mini_eq_pipewire_routes.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from tests._mini_eq_imports import pipewire_routes as pw_routes
+
+
+def test_output_route_preset_key_encodes_device_and_route_names() -> None:
+ assert (
+ pw_routes.build_output_route_preset_key("alsa_card.pci 0000:00:1f.3", "analog-output-headphones", 8)
+ == "pipewire-route:v1:device=alsa_card.pci%200000%3A00%3A1f.3;route=analog-output-headphones;route-device=8"
+ )
+ assert pw_routes.build_output_route_preset_key("", "analog-output-headphones", 8) is None
+ assert pw_routes.build_output_route_preset_key("alsa_card.test", None, 8) is None
+
+
+def test_output_preset_target_uses_route_key_when_present() -> None:
+ route = pw_routes.PipeWireOutputRoute(
+ device_bound_id=72,
+ device_name="alsa_card.test",
+ index=1,
+ route_device=8,
+ profile=0,
+ priority=200,
+ direction="Output",
+ name="analog-output-headphones",
+ description="Headphones",
+ availability="yes",
+ )
+ route_key = route.output_preset_key
+
+ target = pw_routes.PipeWireOutputPresetTarget("alsa_output.test", route, (route_key, "alsa_output.test"))
+
+ assert route_key == "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=8"
+ assert target.link_key == route_key
+ assert target.has_route_key is True
+
+
+def test_output_preset_target_falls_back_to_output_key_without_route() -> None:
+ target = pw_routes.PipeWireOutputPresetTarget("alsa_output.test", None, ("alsa_output.test",))
+
+ assert target.link_key == "alsa_output.test"
+ assert target.has_route_key is False
diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py
index 7b55272..bd49346 100644
--- a/tests/test_mini_eq_routing.py
+++ b/tests/test_mini_eq_routing.py
@@ -4,6 +4,7 @@
from tests._mini_eq_imports import core, routing
from tests._mini_eq_imports import pipewire_backend as pw_backend
+from tests._mini_eq_imports import pipewire_routes as pw_routes
def make_node(
@@ -11,6 +12,7 @@ def make_node(
name: str | None,
media_class: str = pw_backend.AUDIO_SINK,
properties: dict[str, str] | None = None,
+ device_id: int = 0,
) -> pw_backend.PipeWireNode:
return pw_backend.PipeWireNode(
bound_id=bound_id,
@@ -20,6 +22,7 @@ def make_node(
node_description=None,
application_name=None,
node_dont_move=False,
+ device_id=device_id,
properties=properties or {},
)
@@ -81,6 +84,32 @@ def test_get_sink_uses_wireplumber_node_name() -> None:
assert routing.SystemWideEqController.get_sink(controller, "missing") is None
+def test_output_preset_target_is_cached_until_output_changes() -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ calls: list[str | None] = []
+
+ class FakeBackend(FakeOutputBackend):
+ def output_preset_target_for_sink_name(self, sink_name: str | None) -> pw_routes.PipeWireOutputPresetTarget:
+ calls.append(sink_name)
+ return pw_routes.PipeWireOutputPresetTarget(sink_name, None, (sink_name,) if sink_name else ())
+
+ controller.output_backend = FakeBackend([make_node(1, "speakers"), make_node(2, "hdmi")])
+ controller.output_sink = "speakers"
+
+ assert routing.SystemWideEqController.output_preset_target(controller).keys == ("speakers",)
+ assert routing.SystemWideEqController.output_preset_keys(controller) == ("speakers",)
+ assert routing.SystemWideEqController.output_preset_link_key(controller) == "speakers"
+ assert calls == ["speakers"]
+
+ controller.output_sink = "hdmi"
+ assert routing.SystemWideEqController.output_preset_target(controller).keys == ("hdmi",)
+ assert calls == ["speakers", "hdmi"]
+
+ routing.SystemWideEqController.invalidate_output_preset_target(controller)
+ assert routing.SystemWideEqController.output_preset_target(controller).keys == ("hdmi",)
+ assert calls == ["speakers", "hdmi", "hdmi"]
+
+
def test_get_default_output_sink_name_uses_cached_metadata_by_default() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
backend = FakeDefaultOutputBackend(
@@ -203,6 +232,97 @@ def test_output_object_added_schedules_refresh_only_for_audio_sinks(monkeypatch:
assert len(scheduled_callbacks) == 1
+def test_output_event_idle_invalidates_output_preset_target_cache() -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ controller.accept_output_events = True
+ controller.output_event_source_id = 123
+ controller._output_preset_target_sink = "speakers"
+ controller._output_preset_target = pw_routes.PipeWireOutputPresetTarget("speakers", None, ("speakers",))
+ calls: list[str] = []
+ controller.refresh_followed_output_sink = lambda: calls.append("refresh")
+ controller.outputs_changed_callback = lambda: calls.append("outputs")
+
+ assert routing.SystemWideEqController.on_output_event_idle(controller) is False
+
+ assert controller.output_event_source_id == 0
+ assert controller._output_preset_target_sink is None
+ assert controller._output_preset_target is None
+ assert calls == ["refresh", "outputs"]
+
+
+def test_output_route_param_change_schedules_output_refresh(monkeypatch: pytest.MonkeyPatch) -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ sink = make_node(1, "speakers", device_id=72)
+ route_callback = None
+ calls: list[object] = []
+
+ class FakeBackend(FakeOutputBackend):
+ def connect_device_route_changed(self, device_id: int, callback) -> int:
+ nonlocal route_callback
+ calls.append(("connect-route", device_id))
+ route_callback = callback
+ return 77
+
+ def disconnect_device_handler(self, handler_id: int) -> None:
+ calls.append(("disconnect-route", handler_id))
+
+ controller.output_backend = FakeBackend([sink])
+ controller.output_sink = "speakers"
+ controller.accept_output_events = True
+ controller.output_event_source_id = 0
+ controller.output_route_param_handler_id = 0
+ controller.output_route_param_device_id = 0
+ scheduled_callbacks: list[object] = []
+ monkeypatch.setattr(
+ routing.GLib,
+ "idle_add",
+ lambda callback: scheduled_callbacks.append(callback) or 321,
+ )
+
+ routing.SystemWideEqController.refresh_output_route_param_monitor(controller)
+
+ assert controller.output_route_param_handler_id == 77
+ assert controller.output_route_param_device_id == 72
+ assert calls == [("connect-route", 72)]
+
+ assert route_callback is not None
+ route_callback()
+
+ assert controller.output_event_source_id == 321
+ assert len(scheduled_callbacks) == 1
+
+
+def test_output_route_param_monitor_moves_with_active_output() -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ calls: list[object] = []
+
+ class FakeBackend(FakeOutputBackend):
+ def connect_device_route_changed(self, device_id: int, _callback) -> int:
+ calls.append(("connect-route", device_id))
+ return device_id + 1000
+
+ def disconnect_device_handler(self, handler_id: int) -> None:
+ calls.append(("disconnect-route", handler_id))
+
+ controller.output_backend = FakeBackend(
+ [
+ make_node(1, "speakers", device_id=72),
+ make_node(2, "hdmi", device_id=84),
+ ]
+ )
+ controller.accept_output_events = True
+ controller.output_sink = "speakers"
+ controller.output_route_param_handler_id = 1072
+ controller.output_route_param_device_id = 72
+
+ controller.output_sink = "hdmi"
+ routing.SystemWideEqController.refresh_output_route_param_monitor(controller)
+
+ assert controller.output_route_param_handler_id == 1084
+ assert controller.output_route_param_device_id == 84
+ assert calls == [("disconnect-route", 1072), ("connect-route", 84)]
+
+
def test_follow_system_default_output_enables_follow_mode_and_refreshes() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
controller.follow_default_output = False
@@ -220,6 +340,28 @@ def fake_refresh() -> bool:
assert calls == ["refresh"]
+def test_follow_system_default_output_schedules_refresh_when_output_changes(monkeypatch: pytest.MonkeyPatch) -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ controller.follow_default_output = False
+ controller.output_sink = "speakers"
+ controller.accept_output_events = True
+ controller.output_event_source_id = 0
+ scheduled_callbacks: list[object] = []
+
+ def fake_refresh() -> bool:
+ controller.output_sink = "hdmi"
+ return True
+
+ controller.refresh_followed_output_sink = fake_refresh
+ monkeypatch.setattr(routing.GLib, "idle_add", lambda callback: scheduled_callbacks.append(callback) or 321)
+
+ routing.SystemWideEqController.follow_system_default_output(controller)
+
+ assert controller.follow_default_output is True
+ assert controller.output_event_source_id == 321
+ assert len(scheduled_callbacks) == 1
+
+
def test_switch_output_sink_retargets_running_filter_output_without_restart() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
calls: list[object] = []
@@ -258,6 +400,36 @@ def set_output_sink_name(self, sink_name: str) -> None:
]
+def test_explicit_output_change_schedules_coalesced_output_refresh(monkeypatch: pytest.MonkeyPatch) -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ scheduled_callbacks: list[object] = []
+
+ class FakeBackend(FakeOutputBackend):
+ def move_named_output_stream_to_target(self, _stream_node_name: str, _target_node_name: str) -> None:
+ return
+
+ controller.output_backend = FakeBackend([make_node(1, "speakers"), make_node(2, "hdmi")])
+ controller.output_sink = "speakers"
+ controller.follow_default_output = True
+ controller.accept_output_events = True
+ controller.output_event_source_id = 0
+ controller.running = True
+ controller.filter_node_id = 42
+ controller.filter_output_name = "mini_eq_sink_output"
+ controller.virtual_sink_name = "mini_eq_sink"
+ controller.stream_router = None
+ controller.output_analyzer = None
+ controller.apply_state_to_engine = lambda: None
+ controller.emit_status = lambda _message: None
+ monkeypatch.setattr(routing.GLib, "idle_add", lambda callback: scheduled_callbacks.append(callback) or 321)
+
+ routing.SystemWideEqController.switch_output_sink(controller, "hdmi", explicit=True)
+
+ assert controller.output_sink == "hdmi"
+ assert controller.output_event_source_id == 321
+ assert len(scheduled_callbacks) == 1
+
+
def test_switch_output_sink_falls_back_to_restart_when_filter_retarget_fails() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
calls: list[object] = []
diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py
index 3db8cc1..3b96969 100644
--- a/tests/test_mini_eq_window.py
+++ b/tests/test_mini_eq_window.py
@@ -44,6 +44,22 @@ def remove_css_class(self, css_class: str) -> None:
self.css_classes.discard(css_class)
+class FakeFile:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def get_path(self) -> str:
+ return self.path
+
+
+class FakeOpenDialog:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def open_finish(self, _result: object) -> FakeFile:
+ return FakeFile(self.path)
+
+
def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None:
fake_window.sync_control_switches_from_controller = MethodType(
window.MiniEqWindow.sync_control_switches_from_controller,
@@ -407,6 +423,47 @@ def route_system_audio(enabled: bool) -> None:
]
+def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path) -> None:
+ calls: list[object] = []
+ statuses: list[str] = []
+ apo_path = tmp_path / "HD 650.txt"
+ apo_path.write_text("Preamp: 0 dB\n", encoding="utf-8")
+
+ fake_window = SimpleNamespace(
+ controller=SimpleNamespace(
+ import_apo_preset=lambda path: calls.append(("import", path)) or 7,
+ state_signature=lambda: "imported-signature",
+ build_preset_payload=lambda label: {"name": label},
+ ),
+ selected_band_index=0,
+ current_preset_name="Old",
+ saved_preset_signature="old-signature",
+ output_preset_curve_auto_loaded=True,
+ set_visible_band_count=lambda count: calls.append(("visible-bands", count)),
+ set_curve_revert_baseline=lambda label: calls.append(("baseline", label)),
+ refresh_preset_list=lambda: calls.append("presets"),
+ sync_ui_from_state=lambda: calls.append("sync"),
+ set_status=lambda message: statuses.append(message),
+ notify_control_state_changed=lambda: calls.append("notify"),
+ )
+
+ window.MiniEqWindow.on_import_apo_done(fake_window, FakeOpenDialog(str(apo_path)), None)
+
+ assert fake_window.selected_band_index is None
+ assert fake_window.current_preset_name is None
+ assert fake_window.saved_preset_signature == "imported-signature"
+ assert fake_window.output_preset_curve_auto_loaded is False
+ assert statuses == ["Imported APO: HD 650"]
+ assert calls == [
+ ("import", str(apo_path)),
+ ("visible-bands", 7),
+ ("baseline", "Imported APO: HD 650"),
+ "presets",
+ "sync",
+ "notify",
+ ]
+
+
def test_on_bypass_changed_resets_switch_when_engine_update_fails() -> None:
calls: list[object] = []
bypass_switch = FakeSwitch(False)
diff --git a/tools/check_flatpak_runtime.py b/tools/check_flatpak_runtime.py
index 88da618..9ee89d0 100644
--- a/tools/check_flatpak_runtime.py
+++ b/tools/check_flatpak_runtime.py
@@ -384,7 +384,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument(
"--timeout",
type=float,
- default=8.0,
+ default=20.0,
help="Timeout in seconds for each PipeWire state transition.",
)
parser.add_argument(
diff --git a/tools/check_live_ui_runtime.py b/tools/check_live_ui_runtime.py
index 2ff4443..63dfc90 100755
--- a/tools/check_live_ui_runtime.py
+++ b/tools/check_live_ui_runtime.py
@@ -28,6 +28,8 @@
ANALYZER_NODE_NAME = "mini-eq-analyzer"
PRIMARY_SINK_NAME = "ci_null_sink"
ALT_SINK_NAME = "ci_alt_sink"
+EXPORT_CURRENT_CURVE_ACTION = "Export Current Curve\u2026"
+IMPORT_PRESET_ACTION = "Import Preset\u2026"
DEFAULT_AUDIO_SINK_KEY = "default.audio.sink"
DEFAULT_CONFIGURED_AUDIO_SINK_KEY = "default.configured.audio.sink"
DEFAULT_METADATA_VALUE_TYPE = "Spa:String:JSON"
@@ -241,8 +243,21 @@ def verify_pipewire_gobject_probe(timeout_seconds: float) -> None:
raise RuntimeError(f"pipewire-gobject probe did not see sink {sink_name}")
if not sink.object_serial:
raise RuntimeError(f"pipewire-gobject probe saw {sink_name} without object.serial")
-
- defaults = backend.refresh_defaults()
+ target = backend.output_preset_target_for_sink_name(sink_name)
+ if target.has_route_key:
+ raise RuntimeError(f"pipewire-gobject probe unexpectedly found a route key for {sink_name}")
+ if target.keys != (sink_name,):
+ raise RuntimeError(f"pipewire-gobject probe built unexpected output preset keys: {target.keys!r}")
+
+ defaults = wait_for(
+ "pipewire-gobject configured default sink",
+ lambda: (
+ defaults
+ if (defaults := backend.refresh_defaults()).configured_audio_sink == PRIMARY_SINK_NAME
+ else None
+ ),
+ timeout_seconds,
+ )
if defaults.configured_audio_sink != PRIMARY_SINK_NAME:
raise RuntimeError(
f"pipewire-gobject probe read unexpected configured default sink: {defaults.configured_audio_sink!r}"
@@ -478,6 +493,24 @@ def find_list_item_with_descendant(root, pyatspi, *, descendant_name: str, showi
return None
+def find_accessible_with_descendant(
+ root,
+ pyatspi,
+ *,
+ descendant_name: str,
+ role: str | None = None,
+ showing: bool | None = None,
+):
+ for node in iter_accessibles(root):
+ if role is not None and accessible_role(node) != role:
+ continue
+ if showing is not None and state_contains(node, pyatspi.STATE_SHOWING) != showing:
+ continue
+ if has_descendant_name(node, descendant_name):
+ return node
+ return None
+
+
def snapshot_frames(root, pyatspi) -> list[tuple[str, str, bool]]:
rows = []
for node in iter_accessibles(root):
@@ -573,6 +606,22 @@ def find_list_item_with_descendant(self, root, *, descendant_name: str, showing:
showing=showing,
)
+ def find_with_descendant(
+ self,
+ root,
+ *,
+ descendant_name: str,
+ role: str | None = None,
+ showing: bool | None = None,
+ ):
+ return find_accessible_with_descendant(
+ root,
+ self.pyatspi,
+ descendant_name=descendant_name,
+ role=role,
+ showing=showing,
+ )
+
def visible_switch_with_state(self, root, *, name: str, expected_checked: bool):
node = self.find(root, name=name, role="switch", showing=True)
if node is None or self.checked(node) != expected_checked:
@@ -603,10 +652,44 @@ def run_action(self, node, action_names: tuple[str, ...]) -> None:
)
def activate(self, node) -> None:
+ self.run_action_or_ancestor(node, ("press", "click", "activate", "toggle"))
+
+ def run_action_or_ancestor(self, node, action_names: tuple[str, ...]) -> None:
+ exposed_action_names: list[tuple[str, list[str]]] = []
+ current = node
+ for _depth in range(8):
+ try:
+ action = current.queryAction()
+ except Exception:
+ action = None
+
+ if action is not None:
+ current_actions = []
+ for index in range(action.nActions):
+ name = action.getName(index)
+ current_actions.append(name)
+ if name in action_names:
+ if not action.doAction(index):
+ raise AssertionError(f"AT-SPI {name!r} action failed for {accessible_name(current)!r}")
+ return
+ exposed_action_names.append((accessible_name(current), current_actions))
+
+ try:
+ parent = current.parent
+ except Exception:
+ parent = None
+ if parent is None or parent is current:
+ break
+ current = parent
+
+ # Prefer AT-SPI Action. This fallback is only for GTK children that
+ # expose visible text while their clickable ancestor exposes no action.
try:
- self.run_action(node, ("press", "click", "activate", "toggle"))
- except AssertionError:
self.click(node)
+ except AssertionError as exc:
+ raise AssertionError(
+ f"{accessible_name(node)!r} does not expose an activatable AT-SPI action: {exposed_action_names!r}"
+ ) from exc
def toggle_switch(self, node) -> None:
self.run_action(node, ("toggle",))
@@ -820,6 +903,75 @@ def verify_dropdown_exposes_options(
)
+def find_preset_action(driver: UiDriver, name: str):
+ return driver.find(driver.desktop(), name=name, showing=True) or driver.find_with_descendant(
+ driver.desktop(),
+ descendant_name=name,
+ role="push button",
+ showing=True,
+ )
+
+
+def more_preset_actions_button(driver: UiDriver, frame, timeout_seconds: float):
+ return driver.wait_for_accessible(
+ "More Preset Actions button",
+ lambda: driver.find_with_roles(
+ frame,
+ name="More Preset Actions",
+ roles={"push button", "toggle button"},
+ showing=True,
+ ),
+ min(timeout_seconds, 5.0),
+ )
+
+
+def verify_preset_menu_neutral_state(driver: UiDriver, frame, timeout_seconds: float) -> None:
+ menu_timeout = min(timeout_seconds, 5.0)
+ more_button = more_preset_actions_button(driver, frame, timeout_seconds)
+
+ driver.activate(more_button)
+ try:
+ export_button = driver.wait_for_accessible(
+ "Export Current Curve preset action",
+ lambda: find_preset_action(driver, EXPORT_CURRENT_CURVE_ACTION),
+ menu_timeout,
+ )
+ import_button = driver.wait_for_accessible(
+ "Import Preset preset action",
+ lambda: find_preset_action(driver, IMPORT_PRESET_ACTION),
+ menu_timeout,
+ )
+ if not driver.sensitive(export_button):
+ raise AssertionError("Export Current Curve action should be sensitive")
+ if not driver.sensitive(import_button):
+ raise AssertionError("Import Preset action should be sensitive")
+ if find_preset_action(driver, "Reset to Neutral") is not None:
+ raise AssertionError("Reset to Neutral should be hidden while the curve is already neutral")
+ finally:
+ driver.activate(more_button)
+
+ driver.wait_for_accessible(
+ "More Preset Actions popover to close",
+ lambda: find_preset_action(driver, EXPORT_CURRENT_CURVE_ACTION) is None,
+ menu_timeout,
+ )
+
+
+def reset_curve_through_preset_menu(driver: UiDriver, frame, timeout_seconds: float) -> None:
+ menu_timeout = min(timeout_seconds, 5.0)
+ more_button = more_preset_actions_button(driver, frame, timeout_seconds)
+
+ driver.activate(more_button)
+ reset_button = driver.wait_for_accessible(
+ "Reset to Neutral preset action",
+ lambda: find_preset_action(driver, "Reset to Neutral"),
+ menu_timeout,
+ )
+ if not driver.sensitive(reset_button):
+ raise AssertionError("Reset to Neutral action should be sensitive after editing the curve")
+ driver.activate(reset_button)
+
+
def run_ui_flow(
*,
pyatspi,
@@ -889,6 +1041,24 @@ def run_ui_flow(
required_options=("Notch", "Bell"),
timeout_seconds=timeout_seconds,
)
+ verify_dropdown_exposes_options(
+ driver,
+ frame,
+ combo_name="EQ output",
+ required_options=("Follow system output (CI Null Sink)", "CI Null Sink", "CI Alt Sink"),
+ timeout_seconds=timeout_seconds,
+ )
+ driver.wait_for_accessible(
+ "Output-wide auto preset scope",
+ lambda: driver.find_with_roles(
+ frame,
+ name="Output-wide",
+ roles={"label", "status bar", "text"},
+ showing=True,
+ ),
+ timeout_seconds,
+ )
+ verify_preset_menu_neutral_state(driver, frame, timeout_seconds)
route_switch = driver.wait_for_accessible(
"System-wide EQ switch to start active",
@@ -985,10 +1155,10 @@ def run_ui_flow(
timeout_seconds,
)
- driver.set_numeric_value(gain_spin, 0.0)
+ reset_curve_through_preset_menu(driver, frame, timeout_seconds)
driver.wait_for_accessible(
- "Modified preset state to clear after returning band gain to neutral",
- lambda: not driver.status_is_visible(frame, "Modified"),
+ "Neutral preset state after resetting from preset menu",
+ lambda: driver.status_is_visible(frame, "Neutral"),
timeout_seconds,
)
@@ -1028,7 +1198,8 @@ def run_ui_flow(
print(
"Live UI runtime smoke passed: AT-SPI UI flow, dropdown options, "
- "pipewire-gobject probe, synthetic stream routing, default-output follow, monitor, and shutdown verified."
+ "pipewire-gobject probe, output preset scope, preset menu reset, synthetic stream routing, "
+ "default-output follow, monitor, and shutdown verified."
)
finally:
stop_accessible_event_loop(pyatspi, event_thread)
diff --git a/tools/check_pipewire_gobject.py b/tools/check_pipewire_gobject.py
index 02a53d3..0b95b22 100644
--- a/tools/check_pipewire_gobject.py
+++ b/tools/check_pipewire_gobject.py
@@ -9,6 +9,9 @@
"Core.new",
"Core.load_module",
"Core.set_pipewire_property",
+ "Device.enum_all_params",
+ "Device.enum_params",
+ "Device.new",
"Global.dup_property",
"get_library_version",
"Metadata.new",
@@ -18,6 +21,7 @@
"Param.new_props_controls",
"Registry.new",
"Registry.dup_globals_by_interface",
+ "RouteInfo.new_from_param",
"Stream.new_audio_capture",
"Stream.set_deliver_audio_blocks",
"Stream.set_pipewire_property",