diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19b76bf..4dff599 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 0.8.10 - 2026-10-29
+
+- Remember the A/B Compare setting across restarts, including at login.
+- Update the Flatpak runtime to GNOME 51 and the bundled PipeWire modules to
+ 1.6.10.
+- Support GNOME Shell 51 in the Mini EQ Controls extension.
+
## 0.8.9 - 2026-10-13
- Keep the audio device stable when changing presets or filter types, avoiding
diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml
index fe40e61..cc69fd0 100644
--- a/data/io.github.bhack.mini-eq.metainfo.xml
+++ b/data/io.github.bhack.mini-eq.metainfo.xml
@@ -33,11 +33,11 @@
- https://raw.githubusercontent.com/bhack/mini-eq/v0.8.9/docs/screenshots/mini-eq.png
+ https://raw.githubusercontent.com/bhack/mini-eq/v0.8.10/docs/screenshots/mini-eq.png
Adjust sound output with equalizer controls
- https://raw.githubusercontent.com/bhack/mini-eq/v0.8.9/docs/screenshots/mini-eq-dark.png
+ https://raw.githubusercontent.com/bhack/mini-eq/v0.8.10/docs/screenshots/mini-eq-dark.png
Use the equalizer with dark style
@@ -45,6 +45,15 @@
https://github.com/bhack/mini-eq/issues
https://github.com/bhack/mini-eq
+
+
+
+ - Remember the A/B Compare setting across restarts, including at login.
+ - Update the Flatpak runtime to GNOME 51 and the bundled PipeWire modules to 1.6.10.
+ - Support GNOME Shell 51 in the Mini EQ Controls extension.
+
+
+
diff --git a/docs/development.md b/docs/development.md
index a85dd5d..3041d05 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -180,7 +180,7 @@ bundles NumPy and libebur128 for analyzer and live LUFS support.
Install the local build tools:
```bash
-flatpak --user install flathub org.flatpak.Builder org.gnome.Sdk//50
+flatpak --user install flathub org.flatpak.Builder org.gnome.Sdk//51
```
Build and install the local Flatpak:
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 0cbbfd4..6a0e227 100644
--- a/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json
+++ b/extensions/gnome-shell/mini-eq@bhack.github.io/metadata.json
@@ -2,6 +2,6 @@
"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 auto preset links.",
- "shell-version": ["50"],
+ "shell-version": ["50", "51"],
"url": "https://github.com/bhack/mini-eq"
}
diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml
index dce95e5..83ff47c 100644
--- a/io.github.bhack.mini-eq.yaml
+++ b/io.github.bhack.mini-eq.yaml
@@ -1,6 +1,6 @@
app-id: io.github.bhack.mini-eq
runtime: org.gnome.Platform
-runtime-version: "50"
+runtime-version: "51"
sdk: org.gnome.Sdk
command: mini-eq
finish-args:
@@ -117,8 +117,8 @@ modules:
sources:
- type: git
url: https://gitlab.freedesktop.org/pipewire/pipewire.git
- tag: 1.6.9
- commit: 8fa27cabdc6c0c1350c69c026af5850ef0af1e26
+ tag: 1.6.10
+ commit: f9d6cbc99c354b9b0ad1d37245dc3ab0fb9f9171
- name: pipewire-gobject
buildsystem: meson
diff --git a/pyproject.toml b/pyproject.toml
index a286603..4fbf14e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mini-eq"
-version = "0.8.9"
+version = "0.8.10"
description = "Compact PipeWire system-wide parametric equalizer for Linux desktops."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/python3-dependencies.yaml b/python3-dependencies.yaml
index 23abffd..3a6d54b 100644
--- a/python3-dependencies.yaml
+++ b/python3-dependencies.yaml
@@ -1,5 +1,4 @@
-# Generated with flatpak-pip-generator --runtime org.gnome.Sdk//50 --yaml --output python3-dependencies --prefer-wheels=numpy numpy==2.4.4
-# Updated NumPy to 2.4.6 using CPython 3.13 wheel URLs and SHA-256 hashes from PyPI.
+# NumPy 2.4.6 CPython 3.14 wheel URLs and SHA-256 hashes from PyPI for GNOME SDK 51.
name: python3-dependencies
buildsystem: simple
build-commands: []
@@ -11,12 +10,12 @@ modules:
--prefix=${FLATPAK_DEST} "numpy==2.4.6" --no-build-isolation
sources:
- type: file
- url: https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- sha256: a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089
+ url: https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ sha256: a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8
only-arches:
- x86_64
- type: file
- url: https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
- sha256: 72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b
+ url: https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ sha256: 40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b
only-arches:
- aarch64
diff --git a/src/mini_eq/app.py b/src/mini_eq/app.py
index e514289..538f800 100644
--- a/src/mini_eq/app.py
+++ b/src/mini_eq/app.py
@@ -200,7 +200,9 @@ def start_window_controller(self) -> None:
try:
controller = SystemWideEqController(self.args.output_sink)
if self.args.import_apo:
+ startup_eq_enabled = controller.eq_enabled
controller.import_apo_preset(self.args.import_apo)
+ controller.eq_enabled = startup_eq_enabled
initial_curve_label = imported_apo_curve_label(self.args.import_apo)
except Exception as exc:
if controller is not None:
@@ -363,7 +365,9 @@ def run_headless(args: Namespace) -> int:
controller = SystemWideEqController(args.output_sink)
if args.import_apo:
+ startup_eq_enabled = controller.eq_enabled
controller.import_apo_preset(args.import_apo)
+ controller.eq_enabled = startup_eq_enabled
if duration_ms > 0:
GLib.timeout_add(duration_ms, lambda: (loop.quit(), False)[1])
@@ -373,7 +377,7 @@ def on_ready() -> None:
try:
if args.auto_route:
- controller.route_system_audio(True)
+ controller.route_system_audio(True, preserve_eq_state=True)
except Exception as exc:
exit_code = 1
print(str(exc), file=sys.stderr)
diff --git a/src/mini_eq/dbus_control.py b/src/mini_eq/dbus_control.py
index 2b13c23..69e3d89 100644
--- a/src/mini_eq/dbus_control.py
+++ b/src/mini_eq/dbus_control.py
@@ -7,6 +7,7 @@
from . import __version__
from .analyzer import analyzer_level_to_display_norm
from .core import list_preset_names, sanitize_preset_name
+from .settings import save_eq_enabled
BUS_NAME = "io.github.bhack.mini-eq"
OBJECT_PATH = "/io/github/bhack/mini_eq/Control"
@@ -366,10 +367,12 @@ def set_eq_enabled(self, enabled: bool) -> None:
raise ValueError("Mini EQ is not running")
controller.set_eq_enabled(enabled)
- if window is not None and not window.ui_shutting_down:
- window.refresh_after_eq_state_changed(notify=False)
-
- self.emit_state_changed()
+ try:
+ save_eq_enabled(enabled)
+ finally:
+ if window is not None and not window.ui_shutting_down:
+ window.refresh_after_eq_state_changed(notify=False)
+ self.emit_state_changed()
def set_routing_enabled(self, enabled: bool) -> None:
if self.app.controller is None:
@@ -388,10 +391,13 @@ def set_routing_enabled(self, enabled: bool) -> None:
window.sync_control_switches_from_controller()
raise
- if window is not None and not window.ui_shutting_down:
- window.refresh_after_route_state_changed(eq_was_enabled=eq_was_enabled, notify=False)
-
- self.emit_state_changed()
+ try:
+ if enabled and controller.eq_enabled != eq_was_enabled:
+ save_eq_enabled(controller.eq_enabled)
+ finally:
+ if window is not None and not window.ui_shutting_down:
+ window.refresh_after_route_state_changed(eq_was_enabled=eq_was_enabled, notify=False)
+ self.emit_state_changed()
def set_preset(self, name: str) -> None:
preset_name = sanitize_preset_name(name)
diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py
index 09c649f..bd879a0 100644
--- a/src/mini_eq/routing.py
+++ b/src/mini_eq/routing.py
@@ -54,6 +54,7 @@
)
from .pipewire_routes import PipeWireOutputPresetTarget
from .pipewire_stream_router import PipeWireStreamRouter
+from .settings import load_eq_enabled
FILTER_CONTROL_PARAM_ECHO_GRACE_USEC = 500_000
_OUTPUT_PRESET_TARGET_UNSET = object()
@@ -141,7 +142,7 @@ def __init__(self, output_sink: str | None) -> None:
self.outputs_changed_callback: Callable[[], None] | None = None
self.analyzer_levels_callback: Callable[[list[float]], None] | None = None
self.analyzer_loudness_callback: Callable[[AnalyzerLoudnessSnapshot | None], None] | None = None
- self.eq_enabled = True
+ self.eq_enabled = load_eq_enabled()
self.eq_mode = next(iter(EQ_MODES.values()))
self.preamp_db = 0.0
self.default_bands: list[EqBand] = self.build_default_bands()
@@ -644,7 +645,9 @@ def pick_virtual_sink_name(self) -> str:
return f"{VIRTUAL_SINK_BASE}_{index}"
- def route_system_audio(self, enabled: bool, announce: bool = True, *, refresh_output: bool = True) -> None:
+ def route_system_audio(
+ self, enabled: bool, announce: bool = True, *, refresh_output: bool = True, preserve_eq_state: bool = False
+ ) -> None:
if enabled and getattr(self, "shutting_down", False):
return
@@ -656,7 +659,7 @@ def route_system_audio(self, enabled: bool, announce: bool = True, *, refresh_ou
if enabled:
if not self.running or self.filter_node_id is None:
raise RuntimeError("filter-chain PipeWire EQ is not ready")
- if not self.eq_enabled:
+ if not self.eq_enabled and not preserve_eq_state:
eq_enabled_for_route = True
self.set_eq_enabled(True)
diff --git a/src/mini_eq/settings.py b/src/mini_eq/settings.py
index 7b9e95e..64acc80 100644
--- a/src/mini_eq/settings.py
+++ b/src/mini_eq/settings.py
@@ -10,6 +10,7 @@
SETTINGS_VERSION_KEY: Final = "version"
SETTINGS_VERSION: Final = 1
MONITOR_ENABLED_KEY: Final = "monitor_enabled"
+EQ_ENABLED_KEY: Final = "eq_enabled"
APPEARANCE_KEY: Final = "appearance"
BACKGROUND_MODE_KEY: Final = "background_mode"
START_AT_LOGIN_KEY: Final = "start_at_login"
@@ -17,6 +18,7 @@
BOOL_SETTINGS_KEYS: Final = frozenset(
(
MONITOR_ENABLED_KEY,
+ EQ_ENABLED_KEY,
BACKGROUND_MODE_KEY,
START_AT_LOGIN_KEY,
START_ACTIVE_AT_LOGIN_KEY,
@@ -72,7 +74,7 @@ def load_settings() -> dict[str, object]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
if not isinstance(payload, dict):
@@ -113,3 +115,11 @@ def load_monitor_enabled() -> bool:
def save_monitor_enabled(enabled: bool) -> None:
save_bool_setting(MONITOR_ENABLED_KEY, enabled)
+
+
+def load_eq_enabled() -> bool:
+ return load_bool_setting(EQ_ENABLED_KEY, default=True)
+
+
+def save_eq_enabled(enabled: bool) -> None:
+ save_bool_setting(EQ_ENABLED_KEY, enabled)
diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py
index 18a7353..1252028 100644
--- a/src/mini_eq/window.py
+++ b/src/mini_eq/window.py
@@ -41,7 +41,7 @@
from .gtk_utils import create_dropdown_from_strings
from .pipewire_backend import PipeWireBackendError, PipeWireNode, node_sample_rate, parse_positive_int
from .routing import SystemWideEqController
-from .settings import load_monitor_enabled
+from .settings import load_monitor_enabled, save_eq_enabled
from .window_analyzer import MiniEqWindowAnalyzerMixin
from .window_autoeq import MiniEqWindowAutoEqMixin, initialize_autoeq_window_state
from .window_graph import MiniEqWindowGraphMixin
@@ -420,7 +420,7 @@ def apply_startup_auto_route(self) -> None:
output_sink=getattr(self.controller, "output_sink", None),
)
try:
- self.controller.route_system_audio(True)
+ self.controller.route_system_audio(True, preserve_eq_state=True)
except Exception as exc:
retry_scheduled = self.schedule_startup_auto_route_retry_after_error(exc)
trace_startup_event(
@@ -1043,6 +1043,7 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) ->
self.set_status(str(exc))
def import_apo_preset_path(self, path: str, *, imported_name: str | None = None) -> int:
+ eq_was_enabled = self.controller.eq_enabled
imported_count = self.controller.import_apo_preset(path)
if imported_name is not None:
curve_label = imported_apo_curve_label_for_name(imported_name)
@@ -1061,6 +1062,8 @@ def import_apo_preset_path(self, path: str, *, imported_name: str | None = None)
utility_pane_button.set_active(True)
self.sync_ui_from_state()
self.notify_control_state_changed()
+ if self.controller.eq_enabled != eq_was_enabled:
+ save_eq_enabled(self.controller.eq_enabled)
return imported_count
def on_output_changed(self, combo: Gtk.DropDown, _param: object) -> None:
@@ -1108,7 +1111,10 @@ def on_route_changed(self, switch: Gtk.Switch, state: object | None) -> bool:
try:
self.controller.route_system_audio(enabled)
route_changed = True
+ if enabled and self.controller.eq_enabled != eq_was_enabled:
+ save_eq_enabled(self.controller.eq_enabled)
except Exception as exc:
+ route_changed = False
self.set_status(str(exc))
finally:
self.refresh_after_route_state_changed(
@@ -1126,7 +1132,10 @@ def on_bypass_changed(self, switch: Gtk.Switch, state: object | None) -> bool:
try:
self.controller.set_eq_enabled(enabled)
- self.refresh_after_eq_state_changed(announce_enabled=enabled)
+ try:
+ save_eq_enabled(enabled)
+ finally:
+ self.refresh_after_eq_state_changed(announce_enabled=enabled)
except Exception as exc:
self.sync_control_switches_from_controller(route=False)
self.update_eq_power_indicator()
diff --git a/tests/test_demo_runtime.py b/tests/test_demo_runtime.py
index 8d3d330..fc497c5 100644
--- a/tests/test_demo_runtime.py
+++ b/tests/test_demo_runtime.py
@@ -1,6 +1,19 @@
from tools.demo_runtime import DemoController
+def test_demo_routing_preserves_bypass_on_startup() -> None:
+ controller = DemoController()
+ controller.eq_enabled = False
+
+ controller.route_system_audio(True, preserve_eq_state=True)
+
+ assert controller.routed is True
+ assert controller.eq_enabled is False
+
+ controller.route_system_audio(True)
+ assert controller.eq_enabled is True
+
+
def test_demo_output_transition_consumption() -> None:
controller = DemoController()
first = controller.output_preset_target_transition()
diff --git a/tests/test_mini_eq_app.py b/tests/test_mini_eq_app.py
index 80a1c91..2930162 100644
--- a/tests/test_mini_eq_app.py
+++ b/tests/test_mini_eq_app.py
@@ -612,6 +612,7 @@ def test_auto_route_import_error_does_not_retry(monkeypatch) -> None:
class FakeController:
def __init__(self, output_sink: str | None) -> None:
calls.append(("controller", output_sink))
+ self.eq_enabled = True
def import_apo_preset(self, path: str) -> int:
calls.append(("import", path))
diff --git a/tests/test_mini_eq_dbus_control.py b/tests/test_mini_eq_dbus_control.py
index dbcfdf1..7561aab 100644
--- a/tests/test_mini_eq_dbus_control.py
+++ b/tests/test_mini_eq_dbus_control.py
@@ -390,18 +390,61 @@ def test_dbus_control_ignores_closed_connection_error_during_signal_emit() -> No
assert control.registration_id == 0
-def test_dbus_control_set_eq_enabled_updates_controller_and_window() -> None:
+def test_dbus_control_set_eq_enabled_updates_controller_and_window(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
control, controller, window = make_control()
control.set_eq_enabled(False)
assert controller.eq_enabled is False
assert controller.enabled_values == [False]
+ assert import_mini_eq_module("settings").load_eq_enabled() is False
assert window.bypass_switch.active is False
assert window.bypass_switch.state is False
assert window.update_count == 6
+def test_dbus_control_refreshes_eq_state_when_saving_fails(monkeypatch) -> None:
+ control, controller, window = make_control()
+ connection = FakeConnection()
+ control.connection = connection
+ control.registration_id = 1
+
+ def fail_save(_enabled: bool) -> None:
+ raise OSError("disk full")
+
+ monkeypatch.setattr(dbus_control, "save_eq_enabled", fail_save)
+ with pytest.raises(OSError, match="disk full"):
+ control.set_eq_enabled(False)
+
+ assert controller.eq_enabled is False
+ assert window.bypass_switch.active is False
+ assert window.update_count == 6
+ assert [name for name, _params in connection.signals] == ["StateChanged"]
+
+
+def test_dbus_control_refreshes_routing_state_when_saving_fails(monkeypatch) -> None:
+ control, controller, window = make_control()
+ controller.eq_enabled = False
+ window.bypass_switch.active = False
+ connection = FakeConnection()
+ control.connection = connection
+ control.registration_id = 1
+
+ def fail_save(_enabled: bool) -> None:
+ raise OSError("disk full")
+
+ monkeypatch.setattr(dbus_control, "save_eq_enabled", fail_save)
+ with pytest.raises(OSError, match="disk full"):
+ control.set_routing_enabled(True)
+
+ assert controller.routed is True
+ assert controller.eq_enabled is True
+ assert window.route_switch.active is True
+ assert window.bypass_switch.active is True
+ assert [name for name, _params in connection.signals] == ["StateChanged"]
+
+
def test_dbus_control_set_routing_enabled_updates_controller_and_window() -> None:
control, controller, window = make_control()
@@ -414,7 +457,8 @@ def test_dbus_control_set_routing_enabled_updates_controller_and_window() -> Non
assert window.update_count == 4
-def test_dbus_control_set_routing_enabled_restores_equalized_output() -> None:
+def test_dbus_control_set_routing_enabled_restores_equalized_output(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
control, controller, window = make_control()
controller.eq_enabled = False
window.bypass_switch.active = False
diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py
index 2f07ec6..cf81c0e 100644
--- a/tests/test_mini_eq_routing.py
+++ b/tests/test_mini_eq_routing.py
@@ -2,7 +2,7 @@
import pytest
-from tests._mini_eq_imports import core, routing
+from tests._mini_eq_imports import core, import_mini_eq_module, routing
from tests._mini_eq_imports import pipewire_backend as pw_backend
from tests._mini_eq_imports import pipewire_routes as pw_routes
@@ -129,6 +129,38 @@ def close(self) -> None:
assert calls == ["connect", "close"]
+@pytest.mark.parametrize(
+ ("payload", "expected_enabled"),
+ [(b'{"version": 1, "eq_enabled": false}\n', False), (b"\xff", True)],
+)
+def test_constructor_loads_ab_state_without_rewriting_settings(
+ tmp_path, monkeypatch, payload, expected_enabled
+) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
+ settings = import_mini_eq_module("settings")
+ settings_file = settings.settings_path()
+ settings_file.parent.mkdir(parents=True)
+ settings_file.write_bytes(payload)
+ original = settings_file.read_bytes()
+
+ class FakeBackend(FakeDefaultOutputBackend):
+ def __init__(self) -> None:
+ super().__init__(
+ [make_node(1, "speakers")],
+ cached_defaults=pw_backend.PipeWireDefaults("speakers", None),
+ refreshed_defaults=pw_backend.PipeWireDefaults("speakers", None),
+ )
+
+ def connect(self) -> None:
+ pass
+
+ monkeypatch.setattr(routing, "PipeWireBackend", FakeBackend)
+ controller = routing.SystemWideEqController("speakers")
+
+ assert controller.eq_enabled is expected_enabled
+ assert settings_file.read_bytes() == original
+
+
def test_list_output_sink_names_uses_wireplumber_sinks_and_filters_internal_nodes() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
controller.output_backend = FakeOutputBackend(
@@ -1447,6 +1479,32 @@ def set_eq_enabled(enabled: bool) -> None:
assert controller.routed is True
+def test_startup_route_preserves_bypassed_audio() -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ controller.shutting_down = False
+ controller.running = True
+ controller.filter_node_id = 42
+ controller.routed = False
+ controller.eq_enabled = False
+ controller.virtual_sink_name = "mini_eq_sink"
+ calls: list[str] = []
+
+ class FakeRouter:
+ def enable(self) -> None:
+ calls.append("enable")
+
+ controller.refresh_followed_output_sink = lambda **_kwargs: None
+ controller.ensure_stream_router = lambda: FakeRouter()
+ controller.apply_state_to_engine = lambda: calls.append("bypass" if not controller.eq_enabled else "eq")
+ controller.emit_status = lambda _message: None
+
+ routing.SystemWideEqController.route_system_audio(controller, True, preserve_eq_state=True)
+
+ assert controller.routed is True
+ assert controller.eq_enabled is False
+ assert calls == ["enable", "bypass"]
+
+
def test_route_system_audio_reapplies_current_curve_after_routing() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
controller.shutting_down = False
diff --git a/tests/test_mini_eq_settings.py b/tests/test_mini_eq_settings.py
index 16781f3..f607c51 100644
--- a/tests/test_mini_eq_settings.py
+++ b/tests/test_mini_eq_settings.py
@@ -9,6 +9,28 @@
settings = import_mini_eq_module("settings")
+def test_eq_preference_defaults_on_and_remembers_changes(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ assert settings.load_eq_enabled() is True
+ settings.save_eq_enabled(False)
+ assert settings.load_eq_enabled() is False
+ settings.save_eq_enabled(True)
+ assert settings.load_eq_enabled() is True
+
+
+def test_invalid_utf8_settings_use_defaults_without_rewriting(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ path = settings.settings_path()
+ path.parent.mkdir(parents=True)
+ invalid_data = b'{"eq_enabled": false, "name": "\xff"}\n'
+ path.write_bytes(invalid_data)
+
+ assert settings.load_settings() == {}
+ assert settings.load_eq_enabled() is True
+ assert settings.load_monitor_enabled() is True
+ assert path.read_bytes() == invalid_data
+
+
def test_monitor_preference_defaults_to_on(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", None)
diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py
index 3875642..dcbc798 100644
--- a/tests/test_mini_eq_window.py
+++ b/tests/test_mini_eq_window.py
@@ -357,7 +357,8 @@ def test_startup_ready_applies_startup_state_before_presenting() -> None:
controller = SimpleNamespace(eq_enabled=True, routed=False)
application = SimpleNamespace(prepare_window_startup_notification=lambda _window: calls.append("startup-id"))
- def route_system_audio(enabled: bool) -> None:
+ def route_system_audio(enabled: bool, *, preserve_eq_state: bool) -> None:
+ assert preserve_eq_state
calls.append(("route", enabled))
controller.routed = enabled
@@ -450,7 +451,8 @@ def test_startup_auto_route_idle_routes_after_startup_work() -> None:
calls: list[object] = []
controller = SimpleNamespace(eq_enabled=True, routed=False)
- def route_system_audio(enabled: bool) -> None:
+ def route_system_audio(enabled: bool, *, preserve_eq_state: bool) -> None:
+ assert preserve_eq_state
calls.append(("route", enabled))
controller.routed = enabled
@@ -498,7 +500,8 @@ def test_startup_auto_route_reapplies_preset_when_followed_output_changes() -> N
output_sink="alsa_output.headset",
)
- def route_system_audio(enabled: bool) -> None:
+ def route_system_audio(enabled: bool, *, preserve_eq_state: bool) -> None:
+ assert preserve_eq_state
calls.append(("route", enabled))
controller.routed = enabled
controller.output_sink = "alsa_output.speakers"
@@ -549,7 +552,8 @@ def test_startup_auto_route_retries_until_filter_chain_is_ready(monkeypatch) ->
controller = SimpleNamespace(eq_enabled=True, routed=False)
attempts = 0
- def route_system_audio(enabled: bool) -> None:
+ def route_system_audio(enabled: bool, *, preserve_eq_state: bool) -> None:
+ assert preserve_eq_state
nonlocal attempts
attempts += 1
calls.append(("route", attempts, enabled))
@@ -669,7 +673,8 @@ def fail_route(_enabled: bool) -> None:
assert ("status", "metadata permission denied") in calls
-def test_on_route_changed_syncs_bypass_when_controller_enables_eq() -> None:
+def test_on_route_changed_syncs_bypass_when_controller_enables_eq(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
calls: list[object] = []
controller = SimpleNamespace(eq_enabled=False, routed=False)
route_switch = FakeSwitch(True)
@@ -719,18 +724,29 @@ def route_system_audio(enabled: bool) -> None:
]
-def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path) -> None:
+def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ settings = import_mini_eq_module("settings")
+ settings.save_eq_enabled(False)
calls: list[object] = []
statuses: list[str] = []
apo_path = tmp_path / "HD 650.txt"
apo_path.write_text("Preamp: 0 dB\n", encoding="utf-8")
+ controller = SimpleNamespace(
+ eq_enabled=False,
+ state_signature=lambda: "imported-signature",
+ build_preset_payload=lambda label: {"name": label},
+ )
+
+ def import_apo(path: str) -> int:
+ calls.append(("import", path))
+ controller.eq_enabled = True
+ return 7
+
+ controller.import_apo_preset = import_apo
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},
- ),
+ controller=controller,
selected_band_index=0,
current_preset_name="Old",
saved_preset_signature="old-signature",
@@ -751,6 +767,7 @@ def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path)
assert fake_window.saved_preset_signature == "imported-signature"
assert fake_window.output_preset_auto_applied is False
assert fake_window.output_preset_curve_auto_loaded is False
+ assert settings.load_eq_enabled() is True
assert statuses == ["Imported APO curve"]
assert calls == [
("import", str(apo_path)),
@@ -767,6 +784,7 @@ def test_import_apo_preset_path_uses_autoeq_name_and_reveals_utility_pane() -> N
utility_button = FakeUtilityPaneButton(visible=True)
fake_window = SimpleNamespace(
controller=SimpleNamespace(
+ eq_enabled=True,
import_apo_preset=lambda path: calls.append(("import", path)) or 10,
state_signature=lambda: "imported-signature",
build_preset_payload=lambda label: {"name": label},
@@ -807,6 +825,50 @@ def test_import_apo_preset_path_uses_autoeq_name_and_reveals_utility_pane() -> N
]
+def test_on_bypass_changed_persists_only_user_change(tmp_path, monkeypatch) -> None:
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
+ controller = SimpleNamespace(eq_enabled=True)
+
+ def set_enabled(value: bool) -> None:
+ controller.eq_enabled = value
+
+ controller.set_eq_enabled = set_enabled
+ fake_window = SimpleNamespace(
+ updating_ui=False,
+ controller=controller,
+ refresh_after_eq_state_changed=lambda **_kwargs: None,
+ set_status=lambda _message: None,
+ )
+ assert window.MiniEqWindow.on_bypass_changed(fake_window, FakeSwitch(False), False) is True
+ assert import_mini_eq_module("settings").load_eq_enabled() is False
+
+
+def test_on_bypass_changed_refreshes_after_settings_write_failure(monkeypatch) -> None:
+ controller = SimpleNamespace(eq_enabled=True)
+ calls: list[object] = []
+
+ def set_enabled(value: bool) -> None:
+ controller.eq_enabled = value
+
+ def fail_save(_value: bool) -> None:
+ raise OSError("disk full")
+
+ controller.set_eq_enabled = set_enabled
+ monkeypatch.setattr(window, "save_eq_enabled", fail_save)
+ fake_window = SimpleNamespace(
+ updating_ui=False,
+ controller=controller,
+ refresh_after_eq_state_changed=lambda **kwargs: calls.append(("refresh", kwargs)),
+ sync_control_switches_from_controller=lambda **_kwargs: calls.append("sync"),
+ update_eq_power_indicator=lambda: calls.append("power"),
+ set_status=lambda message: calls.append(("status", message)),
+ )
+
+ assert window.MiniEqWindow.on_bypass_changed(fake_window, FakeSwitch(False), False) is True
+ assert controller.eq_enabled is False
+ assert calls == [("refresh", {"announce_enabled": False}), "sync", "power", ("status", "disk full")]
+
+
def test_on_bypass_changed_resets_switch_when_engine_update_fails() -> None:
calls: list[object] = []
bypass_switch = FakeSwitch(False)
diff --git a/tools/check_live_ui_runtime.py b/tools/check_live_ui_runtime.py
index 3ef4d6f..0efaf95 100755
--- a/tools/check_live_ui_runtime.py
+++ b/tools/check_live_ui_runtime.py
@@ -1318,6 +1318,21 @@ def run_ui_flow(
timeout_seconds,
)
+ compare_switch = driver.wait_for_accessible(
+ "A/B compare switch to turn off before restart",
+ lambda: driver.visible_switch_with_state(frame, name="A/B", expected_checked=True),
+ timeout_seconds,
+ )
+ driver.toggle_switch(compare_switch)
+ driver.wait_for_accessible(
+ "A/B compare switch off",
+ lambda: driver.visible_switch_with_state(frame, name="A/B", expected_checked=False),
+ timeout_seconds,
+ )
+ wait_for(
+ "D-Bus A/B state off", lambda: control_state(timeout_seconds).get("eq_enabled") is False, timeout_seconds
+ )
+
try:
driver.run_action(frame, ("close",))
except AssertionError:
@@ -1337,10 +1352,25 @@ def run_ui_flow(
if not no_traceback(app_log_path):
raise AssertionError(f"Mini EQ logged a traceback on shutdown:\n{app_log_path.read_text(errors='replace')}")
+ # Simulate the hidden auto-route launch used at login with the same
+ # isolated settings directory, then inspect its actual control state.
+ app = start_app(repo_root, wayland_name, app_log_path, ["--background", "--auto-route"])
+ wait_for(
+ "hidden auto-route restart preserving A/B off",
+ lambda: (
+ (state := control_state(timeout_seconds)).get("routed") is True
+ and state.get("eq_enabled") is False
+ and state.get("window_visible") is False
+ ),
+ timeout_seconds,
+ )
+ if app.poll() is not None:
+ raise AssertionError(f"Mini EQ exited after restart:\n{app_log_path.read_text(errors='replace')}")
+
print(
"Live UI runtime smoke passed: AT-SPI UI flow, dropdown options, "
"pipewire-gobject probe, output preset scope, preset menu reset, synthetic stream routing, "
- "default-output follow, active processing links, monitor toggle cycles, and shutdown verified."
+ "default-output follow, active processing links, monitor toggle cycles, shutdown, and A/B restart verified."
)
finally:
stop_accessible_event_loop(pyatspi, event_thread)
diff --git a/tools/demo_runtime.py b/tools/demo_runtime.py
index 589315b..0591ebf 100644
--- a/tools/demo_runtime.py
+++ b/tools/demo_runtime.py
@@ -158,7 +158,11 @@ def set_analyzer_enabled(self, enabled: bool) -> bool:
self.analyzer_loudness_callback(demo_analyzer_loudness())
return True
- def route_system_audio(self, enabled: bool) -> None:
+ def route_system_audio(
+ self, enabled: bool, announce: bool = True, *, refresh_output: bool = True, preserve_eq_state: bool = False
+ ) -> None:
+ if enabled and not preserve_eq_state:
+ self.eq_enabled = True
self.routed = enabled
def follow_system_default_output(self) -> None: