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: