diff --git a/.github/workflows/autoeq-live.yml b/.github/workflows/autoeq-live.yml new file mode 100644 index 0000000..40c9572 --- /dev/null +++ b/.github/workflows/autoeq-live.yml @@ -0,0 +1,32 @@ +name: AutoEQ Live + +on: + workflow_dispatch: + schedule: + - cron: "17 6 * * 1" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +jobs: + autoeq-live: + name: AutoEQ.app compatibility + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Install check dependencies + run: | + python3 -m venv .venv + .venv/bin/python -m pip install --upgrade pip + .venv/bin/python -m pip install numpy + + - name: Run AutoEQ live compatibility check + run: PYTHONPATH=src .venv/bin/python tools/check_autoeq_live.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a354903..42338f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,7 @@ jobs: README.md|pyproject.toml|MANIFEST.in|src/*|tests/*|data/*|extensions/*) test=true ;; - tools/check_gnome_shell_extension.py|tools/check_headless_pipewire_runtime.py|tools/pack_gnome_shell_extension.sh|tools/prepare_release.py|tools/release_gates.py|tools/release_preflight.py|tools/release_runtime_gate.py|tools/release_status.py|tools/run_headless_pipewire_runtime_smoke_ci.sh|tools/run_live_ui_runtime_smoke_ci.sh) + tools/check_autoeq_live.py|tools/check_gnome_shell_extension.py|tools/check_headless_pipewire_runtime.py|tools/pack_gnome_shell_extension.sh|tools/prepare_release.py|tools/release_gates.py|tools/release_preflight.py|tools/release_runtime_gate.py|tools/release_status.py|tools/run_headless_pipewire_runtime_smoke_ci.sh|tools/run_live_ui_runtime_smoke_ci.sh) test=true ;; esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ec97e..749cafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.8.0 - 2026-05-13 + +- Add direct AutoEq profile search, curve preview, and on-demand headphone + correction import. +- Show generated AutoEq target, preamp, source, and filter count before + importing a profile. +- Remember window size across launches and show current release notes in the + About dialog. +- Clarify modified curves and auto preset state in the GNOME Shell extension. + ## 0.7.4 - 2026-05-11 - Run filter-chain readiness callbacks from the GLib main context when PipeWire diff --git a/MANIFEST.in b/MANIFEST.in index 69c0d39..1a17f1e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ include python3-dependencies.yaml include src/mini_eq/style.css include src/mini_eq/assets/icons/hicolor/scalable/apps/*.svg include src/mini_eq/assets/icons/hicolor/symbolic/apps/*.svg +include src/mini_eq/assets/schemas/*.gschema.xml include data/*.desktop include data/*.metainfo.xml include docs/flathub.md diff --git a/README.md b/README.md index fd29cc2..cb9f308 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,21 @@ also show live LUFS loudness. 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 auto preset links. -- Equalizer APO preset import from the UI or `--import-apo`, including - compatible presets exported by [AutoEq](https://autoeq.app/). +- Search and import headphone correction presets from + [AutoEq](https://autoeq.app/) directly in the app, or import Equalizer + APO-style text presets from a local file or `--import-apo`. ## AutoEq And APO Presets -Mini EQ can import Equalizer APO-style parametric EQ text presets. This makes it -usable with headphone correction presets exported by -[AutoEq](https://autoeq.app/): export an Equalizer APO/parametric EQ preset from -AutoEq, then use **Import Equalizer APO...** in Mini EQ or start the app with -`mini-eq --import-apo path/to/ParametricEQ.txt`. The -[AutoEq project](https://github.com/jaakkopasanen/AutoEq) provides the source, -headphone measurement data, targets, and optimizer behind the web app. +Mini EQ can search [AutoEq.app](https://autoeq.app/) headphone profiles, +preview the correction curve for a selected profile, and import a generated +Equalizer APO-style parametric EQ preset. Use **Import from AutoEq...** from the +main menu, search by headphone model, select a profile, then import it. Profile +data and generated presets come from AutoEq.app on demand and are cached +locally. + +Local APO text presets are still supported: use **Import Equalizer APO...** in +Mini EQ or start the app with `mini-eq --import-apo path/to/ParametricEQ.txt`. ## Install @@ -75,7 +78,9 @@ https://extensions.gnome.org/extension/9803/mini-eq-controls/ ## Notes -Runtime data is stored under `~/.config/mini-eq`. +User presets and output preset links are stored under `~/.config/mini-eq`. +AutoEq profile data and downloaded presets are cached under +`~/.cache/mini-eq/autoeq`. `pip install mini-eq` installs only the Python package. The recent desktop and audio system packages listed in diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml index 2e04118..d5a7205 100644 --- a/data/io.github.bhack.mini-eq.metainfo.xml +++ b/data/io.github.bhack.mini-eq.metainfo.xml @@ -29,15 +29,15 @@

Mini EQ lets you adjust the sound from your desktop before it reaches your speakers or headphones.

Use ten compact bands to shape bass, mids, and treble, save presets for different outputs, compare the processed sound with the original, and monitor levels while you listen.

-

Import Equalizer APO and AutoEq presets when you want to start from an existing correction for your headphones or speakers.

+

Search AutoEq headphone corrections from the app, preview the curve, or import Equalizer APO presets from local files.

- https://raw.githubusercontent.com/bhack/mini-eq/v0.7.4/docs/screenshots/mini-eq.png + https://raw.githubusercontent.com/bhack/mini-eq/v0.8.0/docs/screenshots/mini-eq.png Adjust sound output with equalizer controls - https://raw.githubusercontent.com/bhack/mini-eq/v0.7.4/docs/screenshots/mini-eq-dark.png + https://raw.githubusercontent.com/bhack/mini-eq/v0.8.0/docs/screenshots/mini-eq-dark.png Use the equalizer with dark style @@ -45,6 +45,16 @@ https://github.com/bhack/mini-eq/issues https://github.com/bhack/mini-eq + + +
    +
  • Add direct AutoEq profile search, curve preview, and on-demand headphone correction import.
  • +
  • Show generated AutoEq target, preamp, source, and filter count before importing a profile.
  • +
  • Remember window size across launches and show current release notes in the About dialog.
  • +
  • Clarify modified curves and auto preset state in the GNOME Shell extension.
  • +
+
+
    diff --git a/docs/development.md b/docs/development.md index ad7b083..74659b9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -97,7 +97,7 @@ python3 -m venv --system-site-packages ~/.local/share/mini-eq/venv ~/.local/share/mini-eq/venv/bin/mini-eq ``` -Install the desktop launcher and icon for the current user: +Install the desktop launcher, icon, and GSettings schema for the current user: ```bash mini-eq --install-desktop diff --git a/docs/flathub.md b/docs/flathub.md index e80a917..fa42140 100644 --- a/docs/flathub.md +++ b/docs/flathub.md @@ -148,6 +148,9 @@ status as the authoritative screenshot-mirroring check. - Mini EQ is an upstream-maintained GTK/Libadwaita graphical application. - The app ID `io.github.bhack.mini-eq` matches the GitHub repository ownership. +- The Flatpak is Wayland-only. Do not add X11 fallback permissions unless the + release intentionally trades the extra screen-contents access warning for X11 + session support. - The app requires `xdg-run/pipewire-0` to create and use PipeWire audio nodes. - PyGObject comes from `org.gnome.Platform`; bundling it from PyPI would risk mismatches with the runtime GLib, GTK, and GObject-Introspection stack. diff --git a/docs/release.md b/docs/release.md index 7f80dd8..ba0d9ac 100644 --- a/docs/release.md +++ b/docs/release.md @@ -120,6 +120,8 @@ python3 tools/prepare_release.py "$version" \ The helper only updates public version metadata: `pyproject.toml`, `CHANGELOG.md`, the top AppStream release entry, and AppStream screenshot tag URLs. It does not commit, tag, publish, or touch maintainer-local release state. +The AppStream release entry is also shown in the app's About dialog, so keep +these notes concise and user-facing. Mini EQ is pre-`1.0.0`. Use patch releases for fixes and listing/package polish, and minor releases for user-facing features or workflow changes. Do not claim @@ -229,6 +231,23 @@ run the app interactively with real music before release. Exercise enable/disable, output switching, preset changes, analyzer display, shutdown, and stream restoration against the actual desktop audio graph. +Run the AutoEQ.app live compatibility check whenever AutoEQ import behavior, +AutoEQ request formatting, or AutoEQ parser assumptions changed: + +```bash +PYTHONPATH=src python3 tools/check_autoeq_live.py +``` + +This check reaches the live AutoEQ.app service, validates the current +`/entries`, `/targets`, and `/equalize` shapes, and imports the generated APO +text with Mini EQ's parser. By default it probes the first parsed AutoEQ +profile, with CLI overrides available for debugging a specific profile. Treat +a failure as a live-service availability or format-drift signal to investigate. +Do not treat temporary AutoEQ.app downtime as an automatic blocker for unrelated +fixes, but decide explicitly whether the release can ship with a known +external-service issue. The `AutoEQ Live` GitHub Actions workflow runs the same +check weekly and can be dispatched manually. + Run the deterministic performance check when a release touches UI responsiveness, graph drawing, filter parameter updates, analyzer work, routing callbacks, or PipeWire event handling. Use `docs/performance.md` for the exact 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 6125a24..4e2f516 100644 --- a/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js +++ b/extensions/gnome-shell/mini-eq@bhack.github.io/extension.js @@ -342,7 +342,9 @@ class MiniEqIndicator extends PanelMenu.Button { ? Boolean(unpackValue(state.analyzer_enabled)) : true; const presetName = unpackValue(state.preset_name) || _('Current State'); + const curveLabel = unpackValue(state.curve_label) || presetName; const outputPresetName = unpackValue(state.output_preset_name) || ''; + const outputPresetLabel = unpackValue(state.output_preset_label) || outputPresetName; const capabilities = unpackValue(state.capabilities) || []; this._capabilities = new Set(Array.isArray(capabilities) ? capabilities : []); const canQuit = this._capabilities.has('quit'); @@ -367,9 +369,9 @@ class MiniEqIndicator extends PanelMenu.Button { this._routingItem.setSensitive(running); this._eqItem.setSensitive(running && routed); this._presetsItem.setSensitive(running); - this._presetsItem.label.text = running ? _('Preset: %s').format(presetName) : _('Presets'); + this._presetsItem.label.text = running ? _('Curve: %s').format(curveLabel) : _('Presets'); this._statusItem.label.text = this._statusText(running, routed, eqEnabled); - this._outputPresetItem.label.text = this._outputPresetText(running, outputPresetName); + this._outputPresetItem.label.text = this._outputPresetText(running, outputPresetLabel); this._quitItem.visible = running && canQuit; } diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml index ce5bd58..897832a 100644 --- a/io.github.bhack.mini-eq.yaml +++ b/io.github.bhack.mini-eq.yaml @@ -4,9 +4,8 @@ runtime-version: "50" sdk: org.gnome.Sdk command: mini-eq finish-args: - - --socket=fallback-x11 - --socket=wayland - - --share=ipc + - --share=network - --device=dri - --filesystem=xdg-run/pipewire-0:ro - --env=PIPEWIRE_MODULE_DIR=/app/lib/pipewire-0.3:/usr/lib/x86_64-linux-gnu/pipewire-0.3:/usr/lib/aarch64-linux-gnu/pipewire-0.3:/usr/lib/pipewire-0.3 @@ -136,6 +135,8 @@ modules: - python3 -m pip install --no-index --no-deps --no-build-isolation --prefix=${FLATPAK_DEST} . - install -Dm644 data/io.github.bhack.mini-eq.desktop -t ${FLATPAK_DEST}/share/applications - install -Dm644 data/io.github.bhack.mini-eq.metainfo.xml -t ${FLATPAK_DEST}/share/metainfo + - install -Dm644 src/mini_eq/assets/schemas/io.github.bhack.mini-eq.gschema.xml -t ${FLATPAK_DEST}/share/glib-2.0/schemas + - glib-compile-schemas --strict ${FLATPAK_DEST}/share/glib-2.0/schemas - cp -a src/mini_eq/assets/icons ${FLATPAK_DEST}/share/ sources: - type: dir diff --git a/pyproject.toml b/pyproject.toml index 451f30d..b489b4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mini-eq" -version = "0.7.4" +version = "0.8.0" description = "Compact PipeWire system-wide parametric equalizer for Linux desktops." readme = "README.md" requires-python = ">=3.11" @@ -77,6 +77,7 @@ mini_eq = [ "style.css", "assets/icons/hicolor/scalable/apps/*.svg", "assets/icons/hicolor/symbolic/apps/*.svg", + "assets/schemas/*.gschema.xml", ] [tool.pytest.ini_options] diff --git a/src/mini_eq/assets/schemas/io.github.bhack.mini-eq.gschema.xml b/src/mini_eq/assets/schemas/io.github.bhack.mini-eq.gschema.xml new file mode 100644 index 0000000..114ee55 --- /dev/null +++ b/src/mini_eq/assets/schemas/io.github.bhack.mini-eq.gschema.xml @@ -0,0 +1,22 @@ + + + + + + 1360 + Window width + The width of the main Mini EQ window. + + + + 720 + Window height + The height of the main Mini EQ window. + + + false + Window maximized state + Whether the main Mini EQ window was maximized. + + + diff --git a/src/mini_eq/autoeq.py b/src/mini_eq/autoeq.py new file mode 100644 index 0000000..122f2d0 --- /dev/null +++ b/src/mini_eq/autoeq.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from urllib.error import URLError +from urllib.request import Request, urlopen + +import gi + +gi.require_version("GLib", "2.0") +from gi.repository import GLib + +from .core import SAMPLE_RATE + +AUTOEQ_APP_ENTRIES_URL = "https://autoeq.app/entries" +AUTOEQ_APP_TARGETS_URL = "https://autoeq.app/targets" +AUTOEQ_APP_EQUALIZE_URL = "https://autoeq.app/equalize" +AUTOEQ_REQUEST_TIMEOUT_SECONDS = 20 +AUTOEQ_ENTRIES_FILE = "entries.json" +AUTOEQ_TARGETS_FILE = "targets.json" +AUTOEQ_PRESET_DIR = "presets" +AUTOEQ_USER_AGENT = "Mini EQ" +AUTOEQ_PARAMETRIC_EQ_CONFIG = "8_PEAKING_WITH_SHELVES" +AUTOEQ_TARGET_COMMENT_PREFIX = "# AutoEq target: " +AUTOEQ_UNKNOWN_TARGET_LABEL = "Unknown" +RE_WHITESPACE = re.compile(r"\s+") + + +@dataclass(frozen=True) +class AutoEqEntry: + name: str + source: str + form: str + rig: str = "" + + @property + def detail(self) -> str: + parts = [part for part in (self.source, self.rig) if part] + return " - ".join(parts) + + @property + def cache_key(self) -> str: + return f"autoeq.app/v1/{self.source}/{self.form}/{self.rig}/{self.name}" + + +@dataclass(frozen=True) +class AutoEqGeneratedPreset: + text: str + target_label: str + + +@dataclass(frozen=True) +class AutoEqDownloadedPreset: + path: Path + target_label: str | None = None + + +def user_cache_dir() -> Path: + return Path(GLib.get_user_cache_dir()) + + +def app_cache_dir() -> Path: + return user_cache_dir() / "mini-eq" + + +def autoeq_cache_dir() -> Path: + return app_cache_dir() / "autoeq" + + +def autoeq_entries_cache_path() -> Path: + return autoeq_cache_dir() / AUTOEQ_ENTRIES_FILE + + +def autoeq_targets_cache_path() -> Path: + return autoeq_cache_dir() / AUTOEQ_TARGETS_FILE + + +def parse_autoeq_app_entries(text: str) -> list[AutoEqEntry]: + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"AutoEq profile list is not valid JSON: {exc.msg}") from exc + + if not isinstance(data, dict): + raise RuntimeError("AutoEq profile list does not have the expected shape") + + entries: list[AutoEqEntry] = [] + seen: set[tuple[str, str, str, str]] = set() + for name, measurements in data.items(): + if not isinstance(name, str) or not isinstance(measurements, list): + continue + + for measurement in measurements: + if not isinstance(measurement, dict): + continue + + source = str(measurement.get("source") or "").strip() + form = str(measurement.get("form") or "").strip() + rig = str(measurement.get("rig") or "").strip() + if not source or not form: + continue + + key = (name.casefold(), source.casefold(), form.casefold(), rig.casefold()) + if key in seen: + continue + + seen.add(key) + entries.append( + AutoEqEntry( + name=name, + source=source, + form=form, + rig=rig, + ) + ) + + return entries + + +def fetch_text(url: str, *, timeout: int = AUTOEQ_REQUEST_TIMEOUT_SECONDS) -> str: + request = Request(url, headers={"User-Agent": AUTOEQ_USER_AGENT}) + try: + with urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8", errors="replace") + except URLError as exc: + reason = getattr(exc, "reason", exc) + raise RuntimeError(f"could not download AutoEq data: {reason}") from exc + + +def post_json( + url: str, + body: dict[str, object], + *, + timeout: int = AUTOEQ_REQUEST_TIMEOUT_SECONDS, +) -> dict[str, object]: + request = Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "User-Agent": AUTOEQ_USER_AGENT, + }, + method="POST", + ) + try: + with urlopen(request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8", errors="replace")) + except URLError as exc: + reason = getattr(exc, "reason", exc) + raise RuntimeError(f"could not download AutoEq data: {reason}") from exc + except json.JSONDecodeError as exc: + raise RuntimeError(f"AutoEq response is not valid JSON: {exc.msg}") from exc + + if not isinstance(data, dict): + raise RuntimeError("AutoEq response does not have the expected shape") + return data + + +def load_autoeq_entries_text(*, refresh: bool = False) -> str: + path = autoeq_entries_cache_path() + + if not refresh and path.is_file(): + return path.read_text(encoding="utf-8", errors="replace") + + text = fetch_text(AUTOEQ_APP_ENTRIES_URL) + parse_autoeq_app_entries(text) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return text + + +def parse_autoeq_targets_data(text: str) -> list[object]: + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"AutoEq target list is not valid JSON: {exc.msg}") from exc + + if not isinstance(data, list): + raise RuntimeError("AutoEq target list does not have the expected shape") + return data + + +def load_autoeq_targets_data(*, refresh: bool = False) -> list[object]: + path = autoeq_targets_cache_path() + + if not refresh and path.is_file(): + text = path.read_text(encoding="utf-8", errors="replace") + else: + text = fetch_text(AUTOEQ_APP_TARGETS_URL) + data = parse_autoeq_targets_data(text) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return data + + return parse_autoeq_targets_data(text) + + +def load_autoeq_entries(*, refresh: bool = False) -> list[AutoEqEntry]: + return parse_autoeq_app_entries(load_autoeq_entries_text(refresh=refresh)) + + +def normalize_search_query(query: str) -> list[str]: + return [token.casefold() for token in RE_WHITESPACE.split(query.strip()) if token] + + +def autoeq_search_score(entry: AutoEqEntry, tokens: list[str]) -> tuple[int, int, int, str]: + name = entry.name.casefold() + detail = f"{entry.source} {entry.rig} {entry.form}".casefold() + haystack = f"{name} {detail}" + + if any(token not in haystack for token in tokens): + return (1_000_000, 1_000_000, len(entry.name), entry.name.casefold()) + + first_token = tokens[0] if tokens else "" + prefix_penalty = 0 if name.startswith(first_token) else 40 + token_distance = sum(max(haystack.find(token), 0) for token in tokens) + source_bonus = 0 if entry.source.casefold() in {"oratory1990", "crinacle", "rtings"} else 8 + return ( + prefix_penalty + token_distance + source_bonus, + len(entry.name), + len(entry.cache_key), + entry.name.casefold(), + ) + + +def search_autoeq_entries(entries: list[AutoEqEntry], query: str, *, limit: int = 80) -> list[AutoEqEntry]: + tokens = normalize_search_query(query) + if not tokens: + return [] + + matched = [entry for entry in entries if autoeq_search_score(entry, tokens)[0] < 1_000_000] + matched.sort(key=lambda entry: autoeq_search_score(entry, tokens)) + return matched[:limit] + + +def autoeq_download_path(entry: AutoEqEntry) -> Path: + directory = autoeq_cache_dir() / AUTOEQ_PRESET_DIR + digest = f"{int.from_bytes(hashlib.sha256(entry.cache_key.encode('utf-8')).digest()[:6], 'big'):012x}" + return directory / f"AutoEq-{digest}.txt" + + +def autoeq_metadata_line(label: str, value: str) -> str: + normalized = RE_WHITESPACE.sub(" ", value).strip() + return f"# AutoEq {label}: {normalized}\n" if normalized else "" + + +def read_cached_autoeq_target_label(entry: AutoEqEntry) -> str | None: + path = autoeq_download_path(entry) + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return None + + for line in lines: + if line.startswith(AUTOEQ_TARGET_COMMENT_PREFIX): + target_label = line.removeprefix(AUTOEQ_TARGET_COMMENT_PREFIX).strip() + return target_label or None + return None + + +def cached_autoeq_target_label(entry: AutoEqEntry) -> str: + target_label = read_cached_autoeq_target_label(entry) + if target_label is not None: + return target_label + + try: + target_label, _bass_boost = autoeq_target_and_bass_boost(entry, load_autoeq_targets_data()) + except Exception: + return AUTOEQ_UNKNOWN_TARGET_LABEL + return target_label + + +def autoeq_target_and_bass_boost( + entry: AutoEqEntry, + targets: list[object], +) -> tuple[str, dict[str, float]]: + preferred_targets: dict[str, dict[str, object]] = {"unknown": {"unknown": {"unknown": "Flat"}}} + bass_boosts: dict[str, dict[str, float]] = {} + + for target in targets: + if not isinstance(target, dict): + continue + + label = str(target.get("label") or "").strip() + if not label: + continue + + bass_boost = target.get("bassBoost") + if isinstance(bass_boost, dict): + bass_boosts[label] = { + "fc": float(bass_boost.get("fc") or 105.0), + "q": float(bass_boost.get("q") or 0.7), + "gain": float(bass_boost.get("gain") or 0.0), + } + + recommended = target.get("recommended") or [] + if not isinstance(recommended, list): + continue + + for measurement_source in recommended: + if not isinstance(measurement_source, dict): + continue + + source = str(measurement_source.get("source") or "").strip() + form = str(measurement_source.get("form") or "").strip() + rig = str(measurement_source.get("rig") or "").strip() + if not source or not form: + continue + + source_targets = preferred_targets.setdefault(source, {}) + if form not in source_targets: + source_targets[form] = {} + + if rig: + form_targets = source_targets[form] + if isinstance(form_targets, dict) and rig not in form_targets: + form_targets[rig] = label + else: + source_targets[form] = label + + target_label = "Flat" + form_targets = preferred_targets.get(entry.source, {}).get(entry.form) + if isinstance(form_targets, str): + target_label = form_targets + elif isinstance(form_targets, dict): + target_label = str(form_targets.get(entry.rig) or target_label) + + bass_boost = bass_boosts.get(target_label, {"fc": 105.0, "q": 0.7, "gain": 0.0}) + return target_label, bass_boost + + +def autoeq_equalize_body(entry: AutoEqEntry, targets: list[object]) -> dict[str, object]: + target_label, bass_boost = autoeq_target_and_bass_boost(entry, targets) + return { + "target": target_label, + "sound_signature": None, + "sound_signature_smoothing_window_size": 1.0, + "bass_boost_gain": bass_boost["gain"], + "bass_boost_fc": bass_boost["fc"], + "bass_boost_q": bass_boost["q"], + "treble_boost_gain": 0.0, + "treble_boost_fc": 10000.0, + "treble_boost_q": 0.7, + "tilt": 0.0, + "fs": int(SAMPLE_RATE), + "bit_depth": 16, + "phase": "minimum", + "f_res": 16.0, + "preamp": 0.0, + "max_gain": 12.0, + "max_slope": 18, + "window_size": 0.08, + "treble_window_size": 2.0, + "treble_f_lower": 6000.0, + "treble_f_upper": 8000.0, + "treble_gain_k": 1.0, + "graphic_eq": False, + "parametric_eq": True, + "fixed_band_eq": False, + "convolution_eq": False, + "response": { + "fr_f_step": 1.02, + "fr_fields": [ + "frequency", + "smoothed", + "error_smoothed", + "target", + "equalization", + "equalized_smoothed", + ], + "base64fp16": True, + }, + "name": entry.name, + "source": entry.source, + "rig": entry.rig, + "parametric_eq_config": AUTOEQ_PARAMETRIC_EQ_CONFIG, + } + + +def format_autoeq_parametric_eq(parametric_eq: object) -> str: + if not isinstance(parametric_eq, dict): + raise RuntimeError("AutoEq response did not include a parametric EQ preset") + + preamp = parametric_eq.get("preamp") + filters = parametric_eq.get("filters") + if not isinstance(preamp, int | float) or not isinstance(filters, list): + raise RuntimeError("AutoEq response did not include a parametric EQ preset") + + filter_type_map = {"LOW_SHELF": "LSC", "PEAKING": "PK", "HIGH_SHELF": "HSC"} + lines = [f"Preamp: {float(preamp):.2f} dB"] + for index, filter_data in enumerate(filters, start=1): + if not isinstance(filter_data, dict): + raise RuntimeError("AutoEq response included an invalid filter") + + filter_type = filter_type_map.get(str(filter_data.get("type") or "")) + fc = filter_data.get("fc") + gain = filter_data.get("gain") + q = filter_data.get("q") + if filter_type is None or not all(isinstance(value, int | float) for value in (fc, gain, q)): + raise RuntimeError("AutoEq response included an invalid filter") + + lines.append( + f"Filter {index}: ON {filter_type} Fc {float(fc):.1f} Hz Gain {float(gain):.1f} dB Q {float(q):.2f}" + ) + + return "\n".join(lines) + "\n" + + +def download_autoeq_app_preset_info(entry: AutoEqEntry, *, refresh: bool = False) -> AutoEqGeneratedPreset: + targets = load_autoeq_targets_data(refresh=refresh) + body = autoeq_equalize_body(entry, targets) + target_label = str(body.get("target") or "Flat") + data = post_json(AUTOEQ_APP_EQUALIZE_URL, body) + return AutoEqGeneratedPreset( + text=format_autoeq_parametric_eq(data.get("parametric_eq")), + target_label=target_label, + ) + + +def download_autoeq_app_preset(entry: AutoEqEntry, *, refresh: bool = False) -> str: + return download_autoeq_app_preset_info(entry, refresh=refresh).text + + +def download_autoeq_preset(entry: AutoEqEntry, *, refresh: bool = False) -> Path: + return download_autoeq_preset_info(entry, refresh=refresh).path + + +def download_autoeq_preset_info( + entry: AutoEqEntry, + *, + refresh: bool = False, +) -> AutoEqDownloadedPreset: + path = autoeq_download_path(entry) + if not refresh and path.is_file(): + return AutoEqDownloadedPreset(path=path, target_label=cached_autoeq_target_label(entry)) + + generated = download_autoeq_app_preset_info(entry, refresh=refresh) + text = generated.text + if "Filter " not in text and "Preamp:" not in text: + raise RuntimeError("downloaded AutoEq preset does not look like an Equalizer APO preset") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(autoeq_metadata_line("target", generated.target_label) + text, encoding="utf-8") + return AutoEqDownloadedPreset(path=path, target_label=generated.target_label) diff --git a/src/mini_eq/dbus_control.py b/src/mini_eq/dbus_control.py index 53bb8c0..5fad5aa 100644 --- a/src/mini_eq/dbus_control.py +++ b/src/mini_eq/dbus_control.py @@ -24,6 +24,19 @@ "output-presets", "analyzer-levels", ) +CURVE_STATUS_BY_PRESET_STATE = { + "preset": "preset", + "modified": "modified", + "neutral": "neutral", + "unsaved": "unsaved", +} +OUTPUT_PRESET_STATUS_LABELS = { + "applied": "Applied", + "different": "Different", + "linked": "Linked", + "missing": "Missing", + "modified": "Modified", +} INTROSPECTION_XML = f""" @@ -117,6 +130,93 @@ def quit_fully(self) -> None: ... def get_dbus_connection(self) -> Gio.DBusConnection | None: ... +def curve_status_from_preset_state(value: object) -> str: + return CURVE_STATUS_BY_PRESET_STATE.get(str(value or "").strip().casefold(), "unknown") + + +def format_curve_label(name: str, status: str) -> str: + label = name.strip() or "Current State" + if status == "modified": + return f"{label} (modified)" + return label + + +def window_curve_display_state(window: object | None) -> tuple[str, str, str]: + if window is None: + return "", "none", "Current State" + + curve_name = "" + curve_status = "unknown" + panel_state_factory = getattr(window, "preset_panel_ui_state", None) + if callable(panel_state_factory): + try: + panel_state = panel_state_factory() + curve_name = str(getattr(panel_state, "current_curve_text", "") or "").strip() + curve_status = curve_status_from_preset_state(getattr(panel_state, "preset_state_text", "")) + except Exception: + curve_name = "" + curve_status = "unknown" + + if not curve_name: + curve_name = str(getattr(window, "current_preset_name", "") or "").strip() or "Current State" + if curve_status == "unknown" and getattr(window, "current_preset_name", None): + curve_status = "preset" + + return curve_name, curve_status, format_curve_label(curve_name, curve_status) + + +def window_output_preset_link_name(window: object | None) -> str: + if window is None: + return "" + + output_preset_link_name = getattr(window, "output_preset_link_name", None) + if not callable(output_preset_link_name): + return "" + + try: + return str(output_preset_link_name() or "").strip() + except Exception: + return "" + + +def window_preset_name_exists(window: object, preset_name: str) -> bool: + preset_name_exists = getattr(window, "preset_name_exists", None) + if not callable(preset_name_exists): + return True + + try: + return bool(preset_name_exists(preset_name)) + except Exception: + return True + + +def window_output_preset_status(window: object | None, preset_name: str) -> str: + if window is None or not preset_name: + return "none" + + if not window_preset_name_exists(window, preset_name): + return "missing" + if bool(getattr(window, "output_preset_auto_applied", False)): + return "applied" + + current_preset_name = getattr(window, "current_preset_name", None) + if current_preset_name == preset_name: + return "modified" + if current_preset_name: + return "different" + return "linked" + + +def format_output_preset_label(preset_name: str, status: str) -> str: + if not preset_name: + return "" + + status_label = OUTPUT_PRESET_STATUS_LABELS.get(status) + if status_label is None: + return preset_name + return f"{status_label} - {preset_name}" + + class MiniEqDbusControl: def __init__(self, app: ApplicationProtocol) -> None: self.app = app @@ -148,11 +248,10 @@ def unregister(self) -> None: def state(self) -> dict[str, GLib.Variant]: controller = self.app.controller window = self.app.window - output_preset_name = "" - if window is not None: - output_preset_link_name = getattr(window, "output_preset_link_name", None) - if output_preset_link_name is not None: - output_preset_name = output_preset_link_name() or "" + curve_name, curve_status, curve_label = window_curve_display_state(window) + output_preset_name = window_output_preset_link_name(window) + output_preset_status = window_output_preset_status(window, output_preset_name) + output_preset_label = format_output_preset_label(output_preset_name, output_preset_status) return { "api_version": GLib.Variant("u", API_VERSION), @@ -164,8 +263,13 @@ def state(self) -> dict[str, GLib.Variant]: "preset_name": GLib.Variant( "s", window.current_preset_name if window and window.current_preset_name else "" ), + "curve_name": GLib.Variant("s", curve_name), + "curve_status": GLib.Variant("s", curve_status), + "curve_label": GLib.Variant("s", curve_label), "output_sink": GLib.Variant("s", controller.output_sink if controller and controller.output_sink else ""), "output_preset_name": GLib.Variant("s", output_preset_name), + "output_preset_status": GLib.Variant("s", output_preset_status), + "output_preset_label": GLib.Variant("s", output_preset_label), "output_preset_auto_applied": GLib.Variant( "b", bool(window and getattr(window, "output_preset_auto_applied", False)), diff --git a/src/mini_eq/desktop_integration.py b/src/mini_eq/desktop_integration.py index 2bbd3db..9349d70 100644 --- a/src/mini_eq/desktop_integration.py +++ b/src/mini_eq/desktop_integration.py @@ -3,6 +3,7 @@ import shutil import subprocess import sys +from importlib.resources import files from pathlib import Path import gi @@ -15,6 +16,8 @@ APP_ID = "io.github.bhack.mini-eq" APP_ICON_NAME = APP_ID APP_ICON_SEARCH_PATH = Path(__file__).resolve().parent / "assets" / "icons" +APP_SCHEMA_NAME = f"{APP_ID}.gschema.xml" +APP_SCHEMA_SOURCE = files("mini_eq").joinpath("assets", "schemas", APP_SCHEMA_NAME) APP_DISPLAY_NAME = "Mini EQ" @@ -34,6 +37,7 @@ def install_desktop_integration() -> None: applications_dir = data_home / "applications" hicolor_source_dir = APP_ICON_SEARCH_PATH / "hicolor" hicolor_target_dir = data_home / "icons" / "hicolor" + schemas_dir = data_home / "glib-2.0" / "schemas" applications_dir.mkdir(parents=True, exist_ok=True) @@ -50,9 +54,11 @@ def install_desktop_integration() -> None: refresh_desktop_database(applications_dir) refresh_icon_cache(hicolor_target_dir) + install_gsettings_schema(schemas_dir) print(f"desktop entry installed: {desktop_file}") print(f"icons installed under: {hicolor_target_dir}") + print(f"GSettings schema installed under: {schemas_dir}") def build_desktop_file() -> str: @@ -96,6 +102,33 @@ def remove_legacy_raster_app_icons(hicolor_dir: Path) -> None: pass +def install_gsettings_schema(schemas_dir: Path) -> Path: + schemas_dir.mkdir(parents=True, exist_ok=True) + target = schemas_dir / APP_SCHEMA_NAME + target.write_bytes(APP_SCHEMA_SOURCE.read_bytes()) + compile_gsettings_schemas(schemas_dir) + return target + + +def compile_gsettings_schemas(schemas_dir: Path) -> None: + glib_compile_schemas = shutil.which("glib-compile-schemas") + if glib_compile_schemas is None: + return + + result = subprocess.run( + [glib_compile_schemas, "--strict", str(schemas_dir)], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() + message = f"could not compile GSettings schemas in {schemas_dir}" + if details: + message = f"{message}: {details}" + raise RuntimeError(message) + + def refresh_desktop_database(applications_dir: Path) -> None: update_desktop_database = shutil.which("update-desktop-database") if update_desktop_database is None: diff --git a/src/mini_eq/release_notes.py b/src/mini_eq/release_notes.py new file mode 100644 index 0000000..20e654a --- /dev/null +++ b/src/mini_eq/release_notes.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import os +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + +APPSTREAM_ID = "io.github.bhack.mini-eq" +APPSTREAM_FILE_NAME = f"{APPSTREAM_ID}.metainfo.xml" + + +@dataclass(frozen=True) +class AboutReleaseNotes: + version: str + markup: str + + +def unique_paths(paths: list[Path]) -> list[Path]: + seen: set[Path] = set() + unique: list[Path] = [] + for path in paths: + resolved = path.expanduser() + if resolved in seen: + continue + + seen.add(resolved) + unique.append(resolved) + return unique + + +def xdg_data_dirs() -> list[Path]: + data_home = os.environ.get("XDG_DATA_HOME") + if data_home and Path(data_home).is_absolute(): + dirs = [Path(data_home)] + else: + dirs = [Path.home() / ".local" / "share"] + + data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" + dirs.extend(Path(path) for path in data_dirs.split(":") if path) + return dirs + + +def appstream_metainfo_paths() -> list[Path]: + source_tree_path = Path(__file__).resolve().parents[2] / "data" / APPSTREAM_FILE_NAME + paths = [ + source_tree_path, + Path(sys.prefix) / "share" / "metainfo" / APPSTREAM_FILE_NAME, + Path("/app/share/metainfo") / APPSTREAM_FILE_NAME, + ] + paths.extend(data_dir / "metainfo" / APPSTREAM_FILE_NAME for data_dir in xdg_data_dirs()) + return unique_paths(paths) + + +def xml_local_name(tag: str) -> str: + if tag.startswith("{"): + return tag.rsplit("}", 1)[1] + return tag + + +def child_elements(element: ET.Element, name: str) -> list[ET.Element]: + return [child for child in list(element) if xml_local_name(child.tag) == name] + + +def appstream_release_notes_markup(description: ET.Element) -> str: + parts: list[str] = [] + if description.text and description.text.strip(): + parts.append(description.text.strip()) + + for child in list(description): + text = ET.tostring(child, encoding="unicode", method="xml").strip() + if text: + parts.append(text) + + return "\n".join(parts).strip() + + +def release_notes_from_metainfo(path: Path, version: str) -> AboutReleaseNotes | None: + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError): + return None + + releases = next(iter(child_elements(root, "releases")), None) + if releases is None: + return None + + for release in child_elements(releases, "release"): + release_version = release.attrib.get("version") + if release_version != version: + continue + + description = next(iter(child_elements(release, "description")), None) + if description is None: + return None + + markup = appstream_release_notes_markup(description) + if not markup: + return None + + return AboutReleaseNotes(version=release_version, markup=markup) + + return None + + +def about_release_notes(version: str) -> AboutReleaseNotes | None: + for path in appstream_metainfo_paths(): + release_notes = release_notes_from_metainfo(path, version) + if release_notes is not None: + return release_notes + + return None diff --git a/src/mini_eq/style.css b/src/mini_eq/style.css index e7750e4..29607ac 100644 --- a/src/mini_eq/style.css +++ b/src/mini_eq/style.css @@ -55,11 +55,6 @@ } .toolbar-button { - padding: 3px 9px; - min-height: 32px; -} - -.utility-pane-dense .toolbar-button { padding: 2px 8px; min-height: 30px; } @@ -75,10 +70,6 @@ min-height: 32px; } -.utility-pane-dense .toolbar-select { - min-height: 30px; -} - .toolbar-compact-actions { padding: 0; border-radius: 0; @@ -137,8 +128,8 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { } .utility-section { - padding: 7px 10px; - border-radius: 12px; + padding: 4px 8px; + border-radius: 10px; border: 1px solid var(--mini-border-soft); background-color: var(--mini-utility-section-bg); } @@ -147,31 +138,12 @@ 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-pane-tight .utility-section { - padding: 4px 8px; -} - .utility-row { - padding: 4px 0; + padding: 1px 0; border-radius: 0; background-color: transparent; } -.utility-pane-dense .utility-row, -.utility-pane-dense .compare-row { - padding: 2px 0; -} - -.utility-pane-tight .utility-row, -.utility-pane-tight .compare-row { - padding: 1px 0; -} - .output-scope-state-label { color: alpha(var(--window-fg-color), 0.86); } @@ -182,13 +154,13 @@ overlay-split-view.mini-eq-workspace > widget.sidebar-pane { } .compare-row { - padding: 4px 0; + padding: 1px 0; border-radius: 0; background-color: transparent; } .preset-row { - margin-top: 2px; + margin-top: 0; } .preset-more-menu { @@ -250,23 +222,13 @@ 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; + padding: 2px 8px; border-radius: 999px; font-size: 9pt; font-weight: 800; } -.utility-pane-dense .preset-state-chip, -.utility-pane-dense .system-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); @@ -288,7 +250,7 @@ button.popover-action.destructive-action:not(:disabled) { } .system-state-chip { - padding: 3px 10px; + padding: 2px 8px; border-radius: 999px; font-size: 9pt; font-weight: 800; @@ -315,35 +277,23 @@ button.popover-action.destructive-action:not(:disabled) { } .headroom-panel { - padding: 4px 8px; + padding: 2px 6px; border-radius: 8px; background-color: transparent; } -.utility-pane-dense .headroom-panel { - padding: 2px 6px; -} - .headroom-panel-risk { background-color: rgba(255, 92, 80, 0.055); } .headroom-state { - font-size: 13pt; + font-size: 12pt; font-weight: 800; color: var(--window-fg-color); } -.utility-pane-dense .headroom-state { - font-size: 12pt; -} - -.utility-pane-tight .headroom-state { - font-size: 11pt; -} - .headroom-peak-chip { - padding: 3px 9px; + padding: 2px 8px; border-radius: 999px; font-size: 9.5pt; font-weight: 800; @@ -372,10 +322,6 @@ button.popover-action.destructive-action:not(:disabled) { } .headroom-preamp-row { - margin-top: 1px; -} - -.utility-pane-dense .headroom-preamp-row { margin-top: 0; } @@ -384,37 +330,19 @@ button.popover-action.destructive-action:not(:disabled) { } .monitor-strip { - margin-top: 1px; - padding-top: 5px; - border-top: 1px solid var(--mini-border-subtle); -} - -.utility-pane-dense .monitor-strip { margin-top: 0; - padding-top: 3px; -} - -.utility-pane-tight .monitor-strip { padding-top: 2px; + border-top: 1px solid var(--mini-border-subtle); } .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; + border-radius: 8px; } .loudness-value-label { @@ -424,10 +352,6 @@ button.popover-action.destructive-action:not(:disabled) { } .loudness-meter-area { - min-height: 14px; -} - -.utility-pane-dense .loudness-meter-area { min-height: 12px; } diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py index 3a30078..d0f3288 100644 --- a/src/mini_eq/window.py +++ b/src/mini_eq/window.py @@ -40,11 +40,13 @@ from .routing import SystemWideEqController from .settings import load_monitor_enabled from .window_analyzer import MiniEqWindowAnalyzerMixin +from .window_autoeq import MiniEqWindowAutoEqMixin, initialize_autoeq_window_state from .window_graph import MiniEqWindowGraphMixin from .window_headroom import MiniEqWindowHeadroomMixin, format_headroom_peak_db from .window_layout import MiniEqWindowLayoutMixin from .window_preferences import MiniEqWindowPreferencesMixin -from .window_presets import MiniEqWindowPresetMixin, imported_apo_curve_label +from .window_presets import MiniEqWindowPresetMixin, imported_apo_curve_label, imported_apo_curve_label_for_name +from .window_state import bind_window_state from .window_utility import MiniEqWindowUtilityPaneMixin from .window_utils import requested_switch_state, set_switch_confirmed_state @@ -125,6 +127,7 @@ def output_preset_target_identity(owner: object, fallback: str | None) -> str | class MiniEqWindow( MiniEqWindowPresetMixin, MiniEqWindowAnalyzerMixin, + MiniEqWindowAutoEqMixin, MiniEqWindowGraphMixin, MiniEqWindowHeadroomMixin, MiniEqWindowUtilityPaneMixin, @@ -154,6 +157,9 @@ def __init__( self.compact_min_window_height = MIN_WINDOW_HEIGHT self.default_min_window_height = MIN_WINDOW_HEIGHT self.set_default_size(1360, DEFAULT_WINDOW_HEIGHT) + self.window_state_settings = bind_window_state(self) + _default_width, default_height = self.get_default_size() + self.initial_layout_height = default_height if default_height > 0 else DEFAULT_WINDOW_HEIGHT self.set_size_request(self.min_window_width, self.compact_min_window_height) self.updating_ui = False self.selected_band_index: int | None = 0 @@ -207,6 +213,7 @@ def __init__( self.analyzer_last_frame_time = time.monotonic() self.utility_pane_button: Gtk.ToggleButton | None = None self.utility_pane_binding: GObject.Binding | None = None + initialize_autoeq_window_state(self) self.fallback_preset_row_visible = False self.default_preset_row: Gtk.Box | None = None self.preset_default_heading: Gtk.Label | None = None @@ -893,21 +900,32 @@ def on_import_apo_done(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> return 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(curve_label) - self.output_preset_curve_auto_loaded = False - self.refresh_preset_list() - self.sync_ui_from_state() + self.import_apo_preset_path(path) self.set_status("Imported APO curve") - self.notify_control_state_changed() except Exception as exc: self.set_status(str(exc)) + def import_apo_preset_path(self, path: str, *, imported_name: str | None = None) -> int: + imported_count = self.controller.import_apo_preset(path) + if imported_name is not None: + curve_label = imported_apo_curve_label_for_name(imported_name) + else: + 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(curve_label) + self.output_preset_auto_applied = False + self.output_preset_curve_auto_loaded = False + self.refresh_preset_list() + utility_pane_button = getattr(self, "utility_pane_button", None) + if utility_pane_button is not None and utility_pane_button.get_visible(): + utility_pane_button.set_active(True) + self.sync_ui_from_state() + self.notify_control_state_changed() + return imported_count + def on_output_changed(self, combo: Gtk.DropDown, _param: object) -> None: if self.updating_output_combo: return diff --git a/src/mini_eq/window_analyzer.py b/src/mini_eq/window_analyzer.py index a885b86..4813b3a 100644 --- a/src/mini_eq/window_analyzer.py +++ b/src/mini_eq/window_analyzer.py @@ -30,6 +30,10 @@ LOUDNESS_METER_MAX_LUFS = 0.0 +def loudness_value_is_displayable(value: float) -> bool: + return math.isfinite(value) and value >= LOUDNESS_METER_MIN_LUFS + + def format_lufs(value: float) -> str: if not math.isfinite(value): return "-inf LUFS" @@ -40,16 +44,18 @@ def loudness_current_lufs(snapshot: AnalyzerLoudnessSnapshot | None) -> float | if snapshot is None: return None - for value in (snapshot.shortterm_lufs, snapshot.momentary_lufs, snapshot.integrated_lufs): - if math.isfinite(value): + for value in (snapshot.shortterm_lufs, snapshot.momentary_lufs): + if loudness_value_is_displayable(value): return value return None def loudness_summary_lufs(snapshot: AnalyzerLoudnessSnapshot | None) -> str | None: + if snapshot is None: + return None value = loudness_current_lufs(snapshot) - return format_lufs(value) if value is not None else None + return format_lufs(value) if value is not None else "--" def optional_lufs(value: float | None) -> str: @@ -88,7 +94,7 @@ def loudness_tooltip_text( def update_loudness_max(current_max: float | None, value: float) -> float | None: - if not math.isfinite(value): + if not loudness_value_is_displayable(value): return current_max if current_max is None or value > current_max: return value diff --git a/src/mini_eq/window_autoeq.py b/src/mini_eq/window_autoeq.py new file mode 100644 index 0000000..6a5e7f2 --- /dev/null +++ b/src/mini_eq/window_autoeq.py @@ -0,0 +1,873 @@ +from __future__ import annotations + +import math +import threading +from pathlib import Path +from typing import Any + +import gi + +gi.require_version("Adw", "1") +gi.require_version("Gtk", "4.0") + +from gi.repository import Adw, Gdk, GLib, Gtk, Pango + +from .appearance import style_manager_is_dark +from .autoeq import ( + AutoEqEntry, + download_autoeq_preset_info, + load_autoeq_entries, + search_autoeq_entries, +) +from .core import ( + GRAPH_DB_MAX, + GRAPH_DB_MIN, + GRAPH_FREQ_MAX, + GRAPH_FREQ_MIN, + SAMPLE_RATE, + EqBand, + parse_apo_file, + stepped_response_frequencies, + total_response_db_at_frequencies, +) +from .glib_utils import destroy_glib_source +from .window_utils import set_accessible_description, set_accessible_label + +AUTOEQ_PREVIEW_STEPS = 192 +AUTOEQ_PREVIEW_DEBOUNCE_MS = 240 +AUTOEQ_SERVICE_URL = "https://autoeq.app/" +AUTOEQ_PREVIEW_DEFAULT_DETAIL = "Select a profile to preview its curve" +AUTOEQ_PREVIEW_CONTENT_HEIGHT = 118 +AUTOEQ_PREVIEW_DEFAULT_DB_LIMIT = 15.0 +AUTOEQ_PREVIEW_DB_TICK_STEP = 5.0 +AUTOEQ_PREVIEW_MAJOR_FREQ_TICKS = (20.0, 100.0, 1000.0, 10000.0, 20000.0) +AUTOEQ_PREVIEW_MINOR_FREQ_TICKS = ( + 50.0, + 200.0, + 500.0, + 2000.0, + 5000.0, +) +AUTOEQ_PREVIEW_FREQ_TICKS = tuple(sorted(AUTOEQ_PREVIEW_MAJOR_FREQ_TICKS + AUTOEQ_PREVIEW_MINOR_FREQ_TICKS)) +AUTOEQ_PREVIEW_FREQ_LABELS = { + 20.0: "20Hz", + 100.0: "100", + 1000.0: "1k", + 10000.0: "10k", + 20000.0: "20kHz", +} + + +def autoeq_row_markup(text: str) -> str: + return GLib.markup_escape_text(text) + + +class MiniEqWindowAutoEqMixin: + def on_import_autoeq_clicked(self, _button: Gtk.Widget) -> None: + active_dialog = getattr(self, "autoeq_dialog", None) + if active_dialog is not None and self.autoeq_dialog_is_active(): + active_dialog.present(self) + GLib.idle_add(self.focus_autoeq_search_entry) + return + + dialog = Adw.Dialog() + dialog.set_title("Import from AutoEq") + dialog.set_content_width(640) + dialog.set_content_height(560) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content.set_margin_top(18) + content.set_margin_bottom(18) + content.set_margin_start(18) + content.set_margin_end(18) + + search_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + search_entry = Gtk.SearchEntry() + search_entry.set_hexpand(True) + search_entry.set_placeholder_text("Search headphones") + set_accessible_label(search_entry, "Search AutoEq Headphones") + search_row.append(search_entry) + + refresh_button = Gtk.Button() + refresh_button.set_icon_name("view-refresh-symbolic") + refresh_button.set_tooltip_text("Refresh AutoEq Profiles") + set_accessible_label(refresh_button, "Refresh AutoEq Profiles") + search_row.append(refresh_button) + content.append(search_row) + + status_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + status_label = Gtk.Label(xalign=0.0) + status_label.set_hexpand(True) + status_label.add_css_class("dim-label") + status_row.append(status_label) + + spinner = Gtk.Spinner() + spinner.set_visible(False) + status_row.append(spinner) + content.append(status_row) + + results_list = Gtk.ListBox() + results_list.set_selection_mode(Gtk.SelectionMode.SINGLE) + results_list.add_css_class("boxed-list") + + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroller.set_min_content_height(240) + scroller.set_vexpand(True) + scroller.set_child(results_list) + content.append(scroller) + + preview_shell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + preview_shell.add_css_class("autoeq-preview") + + preview_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + preview_title = Gtk.Label(label="Select a profile to import", xalign=0.0) + preview_title.set_hexpand(True) + preview_title.set_ellipsize(Pango.EllipsizeMode.END) + preview_title.add_css_class("heading") + preview_header.append(preview_title) + + preview_count_label = Gtk.Label(xalign=1.0) + preview_count_label.add_css_class("dim-label") + preview_header.append(preview_count_label) + preview_shell.append(preview_header) + + preview_area = Gtk.DrawingArea() + preview_area.set_content_height(AUTOEQ_PREVIEW_CONTENT_HEIGHT) + preview_area.set_hexpand(True) + set_accessible_label(preview_area, "AutoEq curve preview") + set_accessible_description(preview_area, "No AutoEq profile selected") + preview_area.set_draw_func(self.on_autoeq_preview_draw) + preview_shell.append(preview_area) + + preview_detail = Gtk.Label(label=AUTOEQ_PREVIEW_DEFAULT_DETAIL, xalign=0.0) + preview_detail.set_hexpand(True) + preview_detail.set_ellipsize(Pango.EllipsizeMode.END) + preview_detail.add_css_class("dim-label") + preview_shell.append(preview_detail) + content.append(preview_shell) + + footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + footer.set_hexpand(True) + + attribution_link = Gtk.LinkButton(uri=AUTOEQ_SERVICE_URL, label="Generated by AutoEq") + attribution_link.set_halign(Gtk.Align.START) + attribution_link.set_hexpand(True) + attribution_link.set_tooltip_text("Open autoeq.app") + footer.append(attribution_link) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + actions.set_halign(Gtk.Align.END) + + cancel_button = Gtk.Button(label="Cancel") + cancel_button.connect("clicked", lambda _button: dialog.close()) + actions.append(cancel_button) + + import_button = Gtk.Button(label="Import") + import_button.add_css_class("suggested-action") + import_button.set_sensitive(False) + actions.append(import_button) + footer.append(actions) + content.append(footer) + + self.autoeq_dialog = dialog + self.autoeq_search_entry = search_entry + self.autoeq_refresh_button = refresh_button + self.autoeq_spinner = spinner + self.autoeq_status_label = status_label + self.autoeq_results_list = results_list + self.autoeq_import_button = import_button + self.autoeq_cancel_button = cancel_button + self.autoeq_preview_title = preview_title + self.autoeq_preview_count_label = preview_count_label + self.autoeq_preview_area = preview_area + self.autoeq_preview_detail = preview_detail + self.autoeq_import_in_progress = False + self.autoeq_selected_entry = None + self.autoeq_preview_path = None + self.autoeq_preview_preamp_db = None + self.autoeq_preview_target_label = None + self.autoeq_preview_bands = [] + self.autoeq_preview_error = None + self.autoeq_preview_loading = False + self.autoeq_preview_source_id = 0 + self.autoeq_dialog_closed = False + self.autoeq_profiles_request_id += 1 + self.autoeq_import_request_id += 1 + self.autoeq_preview_request_id += 1 + + search_entry.connect("search-changed", self.on_autoeq_search_changed) + search_entry.connect("activate", self.on_autoeq_search_entry_activated) + refresh_button.connect("clicked", lambda _button: self.start_autoeq_profiles_load(refresh=True)) + results_list.connect("row-selected", self.on_autoeq_row_selected) + import_button.connect("clicked", self.on_autoeq_import_clicked) + result_keys = Gtk.EventControllerKey() + result_keys.connect("key-pressed", self.on_autoeq_results_key_pressed) + results_list.add_controller(result_keys) + dialog.connect("closed", self.on_autoeq_dialog_closed) + + dialog.set_child(content) + dialog.set_default_widget(import_button) + dialog.set_focus(search_entry) + dialog.present(self) + self.start_autoeq_profiles_load(refresh=False) + + def autoeq_dialog_is_active(self) -> bool: + return bool( + getattr(self, "autoeq_dialog", None) is not None and not getattr(self, "autoeq_dialog_closed", False) + ) + + def on_autoeq_dialog_closed(self, dialog: Adw.Dialog) -> None: + if dialog is getattr(self, "autoeq_dialog", None): + self.cleanup_autoeq_dialog() + + def cleanup_autoeq_dialog(self) -> None: + if getattr(self, "autoeq_dialog_closed", False): + return + + self.autoeq_dialog_closed = True + self.autoeq_profiles_request_id += 1 + self.autoeq_import_request_id += 1 + self.autoeq_preview_request_id += 1 + + if self.autoeq_preview_source_id > 0: + destroy_glib_source(self.autoeq_preview_source_id) + self.autoeq_preview_source_id = 0 + + spinner = getattr(self, "autoeq_spinner", None) + if spinner is not None: + spinner.stop() + spinner.set_visible(False) + + self.autoeq_import_in_progress = False + self.autoeq_dialog = None + self.autoeq_search_entry = None + self.autoeq_refresh_button = None + self.autoeq_spinner = None + self.autoeq_status_label = None + self.autoeq_results_list = None + self.autoeq_import_button = None + self.autoeq_cancel_button = None + self.autoeq_preview_title = None + self.autoeq_preview_count_label = None + self.autoeq_preview_area = None + self.autoeq_preview_detail = None + + def focus_autoeq_search_entry(self) -> bool: + search_entry = getattr(self, "autoeq_search_entry", None) + if search_entry is not None: + search_entry.grab_focus() + return False + + def set_autoeq_busy(self, busy: bool, message: str) -> None: + self.autoeq_status_label.set_text(message) + self.autoeq_spinner.set_visible(busy) + if busy: + self.autoeq_spinner.start() + else: + self.autoeq_spinner.stop() + + self.autoeq_refresh_button.set_sensitive(not busy) + self.autoeq_search_entry.set_sensitive(not busy) + self.autoeq_results_list.set_sensitive(not busy) + self.update_autoeq_import_button_sensitivity(busy=busy) + + def selected_autoeq_entry(self) -> AutoEqEntry | None: + results_list = getattr(self, "autoeq_results_list", None) + if results_list is None: + return None + + row = results_list.get_selected_row() + entry = getattr(row, "autoeq_entry", None) + return entry if isinstance(entry, AutoEqEntry) else None + + def can_import_autoeq_preview(self) -> bool: + results_list = getattr(self, "autoeq_results_list", None) + entry = self.selected_autoeq_entry() + path = self.autoeq_preview_path + return ( + results_list is not None + and results_list.get_sensitive() + and entry is not None + and entry == self.autoeq_selected_entry + and path is not None + and Path(path).is_file() + and self.autoeq_preview_target_label is not None + and self.autoeq_preview_error is None + and not self.autoeq_preview_loading + and not getattr(self, "autoeq_import_in_progress", False) + ) + + def update_autoeq_import_button_sensitivity(self, *, busy: bool = False) -> None: + import_button = getattr(self, "autoeq_import_button", None) + if import_button is not None: + import_button.set_sensitive(not busy and self.can_import_autoeq_preview()) + + def set_autoeq_import_in_progress(self, in_progress: bool) -> None: + self.autoeq_import_in_progress = in_progress + dialog = getattr(self, "autoeq_dialog", None) + if dialog is not None: + dialog.set_can_close(not in_progress) + + cancel_button = getattr(self, "autoeq_cancel_button", None) + if cancel_button is not None: + cancel_button.set_sensitive(not in_progress) + + def start_autoeq_profiles_load(self, *, refresh: bool) -> None: + self.autoeq_profiles_request_id += 1 + request_id = self.autoeq_profiles_request_id + self.set_autoeq_busy(True, "Refreshing AutoEq profiles…" if refresh else "Loading AutoEq profiles…") + + def load_profiles() -> None: + try: + entries = load_autoeq_entries(refresh=refresh) + GLib.idle_add(self.finish_autoeq_profiles_load, request_id, entries, None) + except Exception as exc: + GLib.idle_add(self.finish_autoeq_profiles_load, request_id, [], str(exc)) + + threading.Thread(target=load_profiles, daemon=True).start() + + def finish_autoeq_profiles_load(self, request_id: int, entries: list[AutoEqEntry], error: str | None) -> bool: + if request_id != self.autoeq_profiles_request_id or not self.autoeq_dialog_is_active(): + return False + + if error is not None: + self.autoeq_entries = [] + self.set_autoeq_busy(False, error) + self.update_autoeq_results() + GLib.idle_add(self.focus_autoeq_search_entry) + return False + + self.autoeq_entries = entries + self.set_autoeq_busy(False, f"Loaded {len(entries):,} AutoEq profiles") + self.update_autoeq_results() + GLib.idle_add(self.focus_autoeq_search_entry) + return False + + def on_autoeq_search_changed(self, _entry: Gtk.SearchEntry) -> None: + self.update_autoeq_results() + + def clear_autoeq_results(self) -> None: + while row := self.autoeq_results_list.get_row_at_index(0): + self.autoeq_results_list.remove(row) + + def show_autoeq_placeholder(self, message: str) -> None: + row = Gtk.ListBoxRow() + row.set_selectable(False) + row.set_activatable(False) + + label = Gtk.Label(label=message, xalign=0.0) + label.set_hexpand(True) + label.set_wrap(True) + label.add_css_class("dim-label") + label.set_margin_top(12) + label.set_margin_bottom(12) + label.set_margin_start(12) + label.set_margin_end(12) + + row.set_child(label) + self.autoeq_results_list.append(row) + + def update_autoeq_results(self) -> None: + self.clear_autoeq_results() + self.autoeq_import_button.set_sensitive(False) + self.clear_autoeq_preview() + + query = self.autoeq_search_entry.get_text().strip() + if not query: + if self.autoeq_entries: + self.autoeq_status_label.set_text("Search by headphone model") + self.show_autoeq_placeholder("Search by headphone model") + return + + if len(query) < 2: + self.autoeq_status_label.set_text("Keep typing to search") + self.show_autoeq_placeholder("Keep typing to search") + return + + results = search_autoeq_entries(self.autoeq_entries, query, limit=80) + if not results: + self.autoeq_status_label.set_text("No AutoEq profiles found") + self.show_autoeq_placeholder("No AutoEq profiles found") + return + + for entry in results: + self.autoeq_results_list.append(self.make_autoeq_result_row(entry)) + + self.autoeq_status_label.set_text(f"{len(results)} match{'es' if len(results) != 1 else ''}") + + def make_autoeq_result_row(self, entry: AutoEqEntry) -> Gtk.ListBoxRow: + row = Adw.ActionRow() + row.set_title(autoeq_row_markup(entry.name)) + row.set_title_lines(1) + row.set_subtitle(autoeq_row_markup(entry.detail or "AutoEq")) + row.set_subtitle_lines(1) + row.set_tooltip_text(f"{entry.name}\n{entry.detail}" if entry.detail else entry.name) + row.set_selectable(True) + row.set_activatable(True) + row.autoeq_entry = entry # type: ignore[attr-defined] + return row + + def on_autoeq_row_selected(self, _list_box: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None: + self.autoeq_import_button.set_sensitive(False) + entry = getattr(row, "autoeq_entry", None) + if isinstance(entry, AutoEqEntry): + self.schedule_autoeq_preview_load(entry) + else: + self.clear_autoeq_preview() + + def on_autoeq_search_entry_activated(self, _entry: Gtk.SearchEntry) -> None: + self.on_autoeq_import_clicked(self.autoeq_import_button) + + def on_autoeq_results_key_pressed( + self, + _controller: Gtk.EventControllerKey, + keyval: int, + _keycode: int, + _state: Gdk.ModifierType, + ) -> bool: + if keyval not in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}: + return False + + self.on_autoeq_import_clicked(self.autoeq_import_button) + return True + + def on_autoeq_import_clicked(self, _button: Gtk.Button) -> None: + row = self.autoeq_results_list.get_selected_row() + entry = getattr(row, "autoeq_entry", None) + if not isinstance(entry, AutoEqEntry): + return + if not self.can_import_autoeq_preview(): + return + + preview_path = self.autoeq_preview_path + if preview_path is None: + return + + self.autoeq_import_request_id += 1 + request_id = self.autoeq_import_request_id + self.finish_autoeq_import(request_id, entry, str(preview_path), None) + + def finish_autoeq_import(self, request_id: int, entry: AutoEqEntry, path: str, error: str | None) -> bool: + if request_id != self.autoeq_import_request_id or not self.autoeq_dialog_is_active(): + return False + + if error is not None: + self.set_autoeq_import_in_progress(False) + self.set_autoeq_busy(False, error) + return False + + try: + imported_count = self.import_apo_preset_path(path, imported_name=entry.name) + except Exception as exc: + self.set_autoeq_import_in_progress(False) + self.set_autoeq_busy(False, str(exc)) + return False + + self.set_autoeq_import_in_progress(False) + self.set_autoeq_busy(False, f"Imported {imported_count} band(s) from {entry.name}") + self.set_status(f"Imported AutoEq Preset: {entry.name}") + dialog = getattr(self, "autoeq_dialog", None) + if dialog is not None: + dialog.force_close() + return False + + def clear_autoeq_preview(self) -> None: + if self.autoeq_preview_source_id > 0: + destroy_glib_source(self.autoeq_preview_source_id) + self.autoeq_preview_source_id = 0 + + self.autoeq_selected_entry = None + self.autoeq_preview_path = None + self.autoeq_preview_preamp_db = None + self.autoeq_preview_target_label = None + self.autoeq_preview_bands = [] + self.autoeq_preview_error = None + self.autoeq_preview_loading = False + self.autoeq_preview_request_id += 1 + + self.autoeq_preview_title.set_text("Select a profile to import") + self.autoeq_preview_count_label.set_text("") + self.autoeq_preview_detail.set_text(AUTOEQ_PREVIEW_DEFAULT_DETAIL) + self.set_autoeq_preview_accessible_description("No AutoEq profile selected") + self.autoeq_preview_area.queue_draw() + + def schedule_autoeq_preview_load(self, entry: AutoEqEntry) -> None: + if self.autoeq_preview_source_id > 0: + destroy_glib_source(self.autoeq_preview_source_id) + self.autoeq_preview_source_id = 0 + + self.autoeq_selected_entry = entry + self.autoeq_preview_path = None + self.autoeq_preview_preamp_db = None + self.autoeq_preview_target_label = None + self.autoeq_preview_bands = [] + self.autoeq_preview_error = None + self.autoeq_preview_loading = False + self.autoeq_preview_request_id += 1 + request_id = self.autoeq_preview_request_id + + self.autoeq_preview_title.set_text("Curve Preview") + self.autoeq_preview_count_label.set_text("Preview") + self.autoeq_preview_detail.set_text(entry.detail or "AutoEq") + self.set_autoeq_preview_accessible_description(f"AutoEq curve preview for {entry.name}") + self.autoeq_preview_area.queue_draw() + self.autoeq_preview_source_id = GLib.timeout_add( + AUTOEQ_PREVIEW_DEBOUNCE_MS, + self.on_autoeq_preview_debounce_timeout, + request_id, + entry, + ) + + def on_autoeq_preview_debounce_timeout(self, request_id: int, entry: AutoEqEntry) -> bool: + self.autoeq_preview_source_id = 0 + if request_id != self.autoeq_preview_request_id or self.autoeq_selected_entry != entry: + return False + + self.start_autoeq_preview_load(entry, request_id=request_id) + return False + + def start_autoeq_preview_load(self, entry: AutoEqEntry, *, request_id: int | None = None) -> None: + if request_id is None: + self.autoeq_preview_request_id += 1 + request_id = self.autoeq_preview_request_id + + self.autoeq_selected_entry = entry + self.autoeq_preview_loading = True + self.autoeq_preview_count_label.set_text("Loading") + self.set_autoeq_preview_accessible_description(f"Loading AutoEq curve preview for {entry.name}") + self.autoeq_preview_area.queue_draw() + + def load_preview() -> None: + try: + preset = download_autoeq_preset_info(entry) + preamp, bands = parse_apo_file(str(preset.path)) + GLib.idle_add( + self.finish_autoeq_preview_load, + request_id, + entry, + str(preset.path), + preamp, + bands, + preset.target_label, + None, + ) + except Exception as exc: + GLib.idle_add(self.finish_autoeq_preview_load, request_id, entry, "", 0.0, [], None, str(exc)) + + threading.Thread(target=load_preview, daemon=True).start() + + def finish_autoeq_preview_load( + self, + request_id: int, + entry: AutoEqEntry, + path: str, + preamp_db: float, + bands: list[EqBand], + target_label: str | None, + error: str | None, + ) -> bool: + if ( + request_id != self.autoeq_preview_request_id + or self.autoeq_selected_entry != entry + or not self.autoeq_dialog_is_active() + ): + return False + + self.autoeq_preview_loading = False + self.autoeq_preview_error = error + self.autoeq_preview_path = Path(path) if path else None + self.autoeq_preview_preamp_db = preamp_db if error is None else None + self.autoeq_preview_target_label = target_label if error is None else None + self.autoeq_preview_bands = bands + + if error is not None: + self.autoeq_preview_count_label.set_text("Unavailable") + self.autoeq_preview_detail.set_text(error) + self.set_autoeq_preview_accessible_description( + f"AutoEq curve preview unavailable for {entry.name}: {error}" + ) + else: + self.autoeq_preview_count_label.set_text(f"{len(bands)} filters") + detail = entry.detail or "AutoEq" + self.autoeq_preview_detail.set_text(self.autoeq_preview_detail_text(preamp_db, detail, target_label)) + description_parts = [f"{len(bands)} filters", f"preamp {preamp_db:+.1f} dB"] + if target_label: + description_parts.append(f"target {target_label}") + self.set_autoeq_preview_accessible_description( + f"AutoEq curve preview for {entry.name}: {', '.join(description_parts)}" + ) + + self.update_autoeq_import_button_sensitivity() + self.autoeq_preview_area.queue_draw() + return False + + def autoeq_preview_detail_text(self, preamp_db: float, detail: str, target_label: str | None) -> str: + parts = [] + if target_label: + parts.append(f"Target: {target_label}") + parts.append(f"Preamp {preamp_db:+.1f} dB") + if detail: + parts.append(detail) + return " - ".join(parts) + + def set_autoeq_preview_accessible_description(self, description: str) -> None: + preview_area = getattr(self, "autoeq_preview_area", None) + if preview_area is not None: + set_accessible_description(preview_area, description) + + def autoeq_preview_palette(self) -> dict[str, tuple[float, float, float, float]]: + try: + dark = style_manager_is_dark(getattr(self, "style_manager", None)) + except Exception: + dark = False + + if dark: + return { + "background": (1.0, 1.0, 1.0, 0.06), + "border": (1.0, 1.0, 1.0, 0.16), + "grid": (1.0, 1.0, 1.0, 0.16), + "grid_major": (1.0, 1.0, 1.0, 0.26), + "axis": (1.0, 1.0, 1.0, 0.36), + "label": (0.92, 0.95, 0.98, 0.58), + "message": (0.92, 0.95, 0.98, 0.72), + "response_shadow": (1.0, 0.58, 0.18, 0.36), + "response": (1.0, 0.62, 0.22, 1.0), + } + + return { + "background": (0.04, 0.07, 0.10, 0.05), + "border": (0.04, 0.08, 0.12, 0.16), + "grid": (0.10, 0.16, 0.22, 0.14), + "grid_major": (0.10, 0.16, 0.22, 0.24), + "axis": (0.10, 0.16, 0.22, 0.36), + "label": (0.12, 0.15, 0.18, 0.58), + "message": (0.12, 0.15, 0.18, 0.72), + "response_shadow": (0.82, 0.34, 0.02, 0.28), + "response": (0.82, 0.34, 0.02, 1.0), + } + + def autoeq_preview_db_limit(self, response) -> float: + response_values = [abs(float(db_value)) for db_value in response] + if not response_values: + return AUTOEQ_PREVIEW_DEFAULT_DB_LIMIT + + max_response = max(response_values) + stepped_limit = math.ceil(max_response / AUTOEQ_PREVIEW_DB_TICK_STEP) * AUTOEQ_PREVIEW_DB_TICK_STEP + return max(AUTOEQ_PREVIEW_DEFAULT_DB_LIMIT, min(max(abs(GRAPH_DB_MIN), abs(GRAPH_DB_MAX)), stepped_limit)) + + def on_autoeq_preview_draw(self, _area: Gtk.DrawingArea, cr, width: int, height: int) -> None: + width_f = float(width) + height_f = float(height) + radius = 10.0 + palette = self.autoeq_preview_palette() + + cr.set_source_rgba(*palette["background"]) + self.rounded_rectangle(cr, 0.5, 0.5, max(width_f - 1.0, 1.0), max(height_f - 1.0, 1.0), radius) + cr.fill() + + cr.set_source_rgba(*palette["border"]) + self.rounded_rectangle(cr, 0.5, 0.5, max(width_f - 1.0, 1.0), max(height_f - 1.0, 1.0), radius) + cr.stroke() + + bands = self.autoeq_preview_bands + preamp_db = self.autoeq_preview_preamp_db + frequencies: list[float] = [] + response: list[float] = [] + if bands and preamp_db is not None: + frequencies = stepped_response_frequencies(SAMPLE_RATE, AUTOEQ_PREVIEW_STEPS) + response = total_response_db_at_frequencies(bands, preamp_db, SAMPLE_RATE, frequencies) + + db_limit = self.autoeq_preview_db_limit(response) + left, right, top, bottom = self.draw_autoeq_preview_grid(cr, width_f, height_f, palette, db_limit) + + if self.autoeq_preview_loading: + self.draw_autoeq_preview_message(cr, width_f, height_f, "Loading preview", palette["message"]) + return + + if self.autoeq_preview_error is not None: + self.draw_autoeq_preview_message(cr, width_f, height_f, "Preview unavailable", palette["message"]) + return + + if len(response) == 0: + self.draw_autoeq_preview_message(cr, width_f, height_f, "No profile selected", palette["message"]) + return + + plot_width = max(width_f - left - right, 1.0) + plot_height = max(height_f - top - bottom, 1.0) + db_min = -db_limit + db_max = db_limit + + points: list[tuple[float, float]] = [] + for frequency, db_value in zip(frequencies, response, strict=False): + x = self.autoeq_preview_frequency_x(frequency, left, plot_width) + y = self.autoeq_preview_db_y(db_value, top, plot_height, db_min, db_max) + points.append((x, y)) + + if len(points) < 2: + return + + cr.set_source_rgba(*palette["response_shadow"]) + cr.set_line_width(5.0) + cr.move_to(points[0][0], points[0][1]) + for x, y in points[1:]: + cr.line_to(x, y) + cr.stroke() + + cr.set_source_rgba(*palette["response"]) + cr.set_line_width(2.0) + cr.move_to(points[0][0], points[0][1]) + for x, y in points[1:]: + cr.line_to(x, y) + cr.stroke() + + def draw_autoeq_preview_grid( + self, + cr, + width: float, + height: float, + palette: dict[str, tuple[float, float, float, float]], + db_limit: float, + ) -> tuple[float, float, float, float]: + left = 34.0 + right = 28.0 + top = 12.0 + bottom = 22.0 + plot_width = max(width - left - right, 1.0) + plot_height = max(height - top - bottom, 1.0) + db_min = -db_limit + db_max = db_limit + + cr.save() + cr.set_line_width(1.0) + + for frequency in AUTOEQ_PREVIEW_FREQ_TICKS: + x = self.autoeq_preview_frequency_x(frequency, left, plot_width) + major = frequency in AUTOEQ_PREVIEW_MAJOR_FREQ_TICKS + cr.set_source_rgba(*(palette["grid_major"] if major else palette["grid"])) + cr.move_to(x, top) + cr.line_to(x, top + plot_height) + cr.stroke() + + tick_start = math.ceil(db_min / AUTOEQ_PREVIEW_DB_TICK_STEP) * AUTOEQ_PREVIEW_DB_TICK_STEP + tick = tick_start + while tick <= db_max + 0.01: + y = self.autoeq_preview_db_y(tick, top, plot_height, db_min, db_max) + cr.set_source_rgba(*(palette["axis"] if abs(tick) < 0.01 else palette["grid_major"])) + cr.move_to(left, y) + cr.line_to(width - right, y) + cr.stroke() + tick += AUTOEQ_PREVIEW_DB_TICK_STEP + + self.draw_autoeq_preview_axis_labels( + cr, width, height, left, right, top, plot_width, plot_height, db_limit, palette + ) + cr.restore() + return left, right, top, bottom + + def draw_autoeq_preview_axis_labels( + self, + cr, + width: float, + height: float, + left: float, + right: float, + top: float, + plot_width: float, + plot_height: float, + db_limit: float, + palette: dict[str, tuple[float, float, float, float]], + ) -> None: + cr.select_font_face("Cantarell") + cr.set_font_size(9.0) + cr.set_source_rgba(*palette["label"]) + db_min = -db_limit + db_max = db_limit + + for tick in (-db_limit, 0.0, db_limit): + label = "0" if abs(tick) < 0.01 else f"{tick:+.0f}" + extents = cr.text_extents(label) + y = self.autoeq_preview_db_y(tick, top, plot_height, db_min, db_max) + cr.move_to(max(4.0, left - extents.width - 6.0), y + (extents.height * 0.5)) + cr.show_text(label) + + label_y = height - 7.0 + for frequency, label in AUTOEQ_PREVIEW_FREQ_LABELS.items(): + extents = cr.text_extents(label) + x = self.autoeq_preview_frequency_x(frequency, left, plot_width) - (extents.width * 0.5) + x = max(left, min(width - right - extents.width, x)) + cr.move_to(x, label_y) + cr.show_text(label) + + def autoeq_preview_frequency_x(self, frequency: float, left: float, plot_width: float) -> float: + log_min = math.log10(GRAPH_FREQ_MIN) + log_max = math.log10(GRAPH_FREQ_MAX) + log_position = (math.log10(float(frequency)) - log_min) / (log_max - log_min) + return left + (plot_width * max(0.0, min(1.0, log_position))) + + def autoeq_preview_db_y( + self, + db_value: float, + top: float, + plot_height: float, + db_min: float, + db_max: float, + ) -> float: + y_norm = (db_max - float(db_value)) / (db_max - db_min) + return top + (plot_height * max(0.0, min(1.0, y_norm))) + + def draw_autoeq_preview_message( + self, + cr, + width: float, + height: float, + text: str, + color: tuple[float, float, float, float], + ) -> None: + cr.select_font_face("Cantarell") + cr.set_font_size(12.0) + cr.set_source_rgba(*color) + extents = cr.text_extents(text) + cr.move_to( + (width - extents.width) * 0.5 - extents.x_bearing, (height - extents.height) * 0.5 - extents.y_bearing + ) + cr.show_text(text) + + def rounded_rectangle(self, cr, x: float, y: float, width: float, height: float, radius: float) -> None: + right = x + width + bottom = y + height + cr.new_sub_path() + cr.arc(right - radius, y + radius, radius, -math.pi * 0.5, 0.0) + cr.arc(right - radius, bottom - radius, radius, 0.0, math.pi * 0.5) + cr.arc(x + radius, bottom - radius, radius, math.pi * 0.5, math.pi) + cr.arc(x + radius, y + radius, radius, math.pi, math.pi * 1.5) + cr.close_path() + + +def initialize_autoeq_window_state(window: Any) -> None: + window.autoeq_entries: list[AutoEqEntry] = [] + window.autoeq_dialog = None + window.autoeq_search_entry = None + window.autoeq_refresh_button = None + window.autoeq_spinner = None + window.autoeq_status_label = None + window.autoeq_results_list = None + window.autoeq_import_button = None + window.autoeq_cancel_button = None + window.autoeq_import_in_progress = False + window.autoeq_dialog_closed = False + window.autoeq_selected_entry: AutoEqEntry | None = None + window.autoeq_preview_path: Path | None = None + window.autoeq_preview_preamp_db: float | None = None + window.autoeq_preview_target_label: str | None = None + window.autoeq_preview_bands: list[EqBand] = [] + window.autoeq_preview_error: str | None = None + window.autoeq_preview_loading = False + window.autoeq_profiles_request_id = 0 + window.autoeq_import_request_id = 0 + window.autoeq_preview_source_id = 0 + window.autoeq_preview_request_id = 0 + window.autoeq_preview_title = None + window.autoeq_preview_count_label = None + window.autoeq_preview_area = None + window.autoeq_preview_detail = None diff --git a/src/mini_eq/window_graph.py b/src/mini_eq/window_graph.py index e5d5208..a25e250 100644 --- a/src/mini_eq/window_graph.py +++ b/src/mini_eq/window_graph.py @@ -12,6 +12,7 @@ from .analyzer import analyzer_db_to_display_norm from .appearance import style_manager_is_dark from .core import ( + DEFAULT_BAND_Q, FILTER_TYPE_INDEX_BY_VALUE, FILTER_TYPE_ORDER, FILTER_TYPES, @@ -39,6 +40,7 @@ GRAPH_PLOT_RIGHT = 62.0 GRAPH_PLOT_TOP = 26.0 GRAPH_PLOT_BOTTOM = 34.0 +SELECTED_BAND_PLACEHOLDER_FREQUENCY_HZ = 1000.0 def rounded_rectangle_path(cr, x: float, y: float, width: float, height: float, radius: float) -> None: @@ -175,6 +177,9 @@ def on_curve_metadata_refresh_idle(self) -> bool: self.update_status_summary() self.update_preset_state() + notify_control_state_changed = getattr(self, "notify_control_state_changed", None) + if callable(notify_control_state_changed): + notify_control_state_changed() return False def schedule_band_engine_update(self, index: int) -> None: @@ -246,12 +251,18 @@ def update_selected_band_editor(self) -> None: if selected_entry is None: self.selected_band_label.set_text("No Band") self.selected_band_label.set_tooltip_text("No band selected") + self.selected_band_type_combo.set_selected(FILTER_TYPE_INDEX_BY_VALUE.get(FILTER_TYPES["Off"], 0)) + self.selected_band_frequency_spin.set_value(SELECTED_BAND_PLACEHOLDER_FREQUENCY_HZ) + self.selected_band_q_spin.set_value(DEFAULT_BAND_Q) + self.selected_band_gain_spin.set_value(0.0) + self.selected_band_mute_button.set_active(False) + self.selected_band_solo_button.set_active(False) for control in editor_controls: control.set_sensitive(False) for group_name in editor_groups: group = getattr(self, group_name, None) if group is not None: - group.set_visible(False) + group.set_visible(True) return selected_index, selected = selected_entry @@ -453,7 +464,7 @@ def on_custom_band_mute_toggled(self, index: int, muted: bool) -> None: self.update_status_summary() self.invalidate_graph_response_cache() self.queue_response_draw() - self.update_preset_state() + self.schedule_curve_metadata_refresh() def on_custom_band_solo_toggled(self, index: int, soloed: bool) -> None: if self.updating_ui: @@ -472,7 +483,7 @@ def on_custom_band_solo_toggled(self, index: int, soloed: bool) -> None: self.update_status_summary() self.invalidate_graph_response_cache() self.queue_response_draw() - self.update_preset_state() + self.schedule_curve_metadata_refresh() def on_selected_band_type_changed(self, combo: Gtk.DropDown, _param: object) -> None: if self.updating_ui: @@ -497,7 +508,7 @@ def on_selected_band_type_changed(self, combo: Gtk.DropDown, _param: object) -> self.update_status_summary() self.invalidate_graph_response_cache() self.queue_response_draw() - self.update_preset_state() + self.schedule_curve_metadata_refresh() def on_selected_band_frequency_changed(self, spin: Gtk.SpinButton) -> None: if self.updating_ui: diff --git a/src/mini_eq/window_layout.py b/src/mini_eq/window_layout.py index 20a1791..bb4efa2 100644 --- a/src/mini_eq/window_layout.py +++ b/src/mini_eq/window_layout.py @@ -25,6 +25,7 @@ clamp, ) from .desktop_integration import APP_ICON_NAME +from .release_notes import about_release_notes from .window_graph import GRAPH_PLOT_BOTTOM, GRAPH_PLOT_LEFT, GRAPH_PLOT_RIGHT, GRAPH_PLOT_TOP from .window_utils import ( bind_label_to_control, @@ -57,8 +58,39 @@ DEFAULT_FADER_SCROLLER_MIN_HEIGHT = 200 COMPACT_FADER_SCROLLER_MIN_HEIGHT = 150 ROOMY_FADER_SCROLLER_MIN_HEIGHT = 290 -UTILITY_DENSE_HEIGHT = 660 -UTILITY_TIGHT_HEIGHT = 620 +COMPACT_UTILITY_PANE_SPACING = 6 +ROOMY_UTILITY_PANE_SPACING = 12 +COMPACT_UTILITY_PANE_MARGIN_TOP = 2 +ROOMY_UTILITY_PANE_MARGIN_TOP = 4 +COMPACT_UTILITY_PANE_MARGIN_BOTTOM = 0 +ROOMY_UTILITY_PANE_MARGIN_BOTTOM = 2 +COMPACT_UTILITY_SECTION_SPACING = 5 +ROOMY_UTILITY_SECTION_SPACING = 8 +COMPACT_HEADROOM_PANEL_SPACING = 3 +ROOMY_HEADROOM_PANEL_SPACING = 7 +COMPACT_HEADROOM_METER_CONTENT_HEIGHT = 9 +ROOMY_HEADROOM_METER_CONTENT_HEIGHT = 14 +COMPACT_MONITOR_PANEL_SPACING = 1 +ROOMY_MONITOR_PANEL_SPACING = 4 + + +def visual_layout_height(owner: object, height: int | None) -> int: + if height is not None and height > 0: + return height + + allocated_height = 0 + get_allocated_height = getattr(owner, "get_allocated_height", None) + if callable(get_allocated_height): + allocated_height = int(get_allocated_height()) + if allocated_height > 0: + return allocated_height + + return max( + int(getattr(owner, "initial_layout_height", 0) or 0), + int(getattr(owner, "default_min_window_height", 0) or 0), + int(getattr(owner, "compact_min_window_height", 0) or 0), + 1, + ) class MiniEqWindowLayoutMixin: @@ -129,6 +161,7 @@ def add_window_action(action_name: str, callback) -> None: action.connect("activate", lambda _action, _parameter: callback()) self.add_action(action) + add_window_action("import-autoeq", lambda: self.on_import_autoeq_clicked(tools_button)) add_window_action("import-apo", lambda: self.on_import_apo_clicked(tools_button)) add_window_action("preferences", self.show_preferences_dialog) add_window_action("about", self.show_about_dialog) @@ -141,6 +174,7 @@ def add_window_action(action_name: str, callback) -> None: self.add_action(self.appearance_action) tools_menu = Gio.Menu() + tools_menu.append("Import from AutoEq…", "win.import-autoeq") tools_menu.append("Import Equalizer APO…", "win.import-apo") appearance_menu = Gio.Menu() @@ -213,12 +247,12 @@ def add_window_action(action_name: str, callback) -> None: left_scroller.set_vexpand(True) left_scroller.set_child(left_column) - right_column = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + right_column = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=COMPACT_UTILITY_PANE_SPACING) right_column.set_size_request(292, -1) right_column.set_vexpand(False) right_column.set_valign(Gtk.Align.START) - right_column.set_margin_top(4) - right_column.set_margin_bottom(2) + right_column.set_margin_top(COMPACT_UTILITY_PANE_MARGIN_TOP) + right_column.set_margin_bottom(COMPACT_UTILITY_PANE_MARGIN_BOTTOM) right_column.set_margin_start(14) right_column.set_margin_end(10) right_column.add_css_class("utility-pane-shell") @@ -295,9 +329,14 @@ def sync_compact_toolbar(_widget: Gtk.Widget | None = None, _param: object | Non sync_compact_toolbar() preset_section = self.make_preset_section() + preset_section.set_spacing(COMPACT_UTILITY_SECTION_SPACING) right_column.append(preset_section) system_section, monitor_panel = self.make_system_section() + system_section.set_spacing(COMPACT_UTILITY_SECTION_SPACING) + self.headroom_panel.set_spacing(COMPACT_HEADROOM_PANEL_SPACING) + self.headroom_meter_area.set_content_height(COMPACT_HEADROOM_METER_CONTENT_HEIGHT) + monitor_panel.set_spacing(COMPACT_MONITOR_PANEL_SPACING) right_column.append(system_section) graph_shell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) @@ -608,7 +647,7 @@ def responsive_value(dense_value: int, roomy_value: int, height: int) -> int: def sync_visual_layout(height: int | None = None) -> None: compact = workspace.get_collapsed() - layout_height = height if height is not None and height > 0 else self.get_allocated_height() + layout_height = visual_layout_height(self, height) graph_height = responsive_value( COMPACT_GRAPH_CONTENT_HEIGHT, @@ -626,29 +665,42 @@ def sync_visual_layout(height: int | None = None) -> None: ROOMY_FADER_SCROLLER_MIN_HEIGHT, layout_height, ) - dense_utility = layout_height <= UTILITY_DENSE_HEIGHT - tight_utility = layout_height <= UTILITY_TIGHT_HEIGHT - if dense_utility: - right_column.add_css_class("utility-pane-dense") - else: - right_column.remove_css_class("utility-pane-dense") - if tight_utility: - right_column.add_css_class("utility-pane-tight") - else: - right_column.remove_css_class("utility-pane-tight") - fallback_row = getattr(self, "default_preset_row", None) if fallback_row is not None: fallback_row.set_visible(getattr(self, "fallback_preset_row_visible", False)) - 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)) - preset_section.set_spacing(responsive_value(5, 8, layout_height)) - system_section.set_spacing(responsive_value(5, 8, layout_height)) - self.headroom_panel.set_spacing(responsive_value(3, 7, layout_height)) - self.headroom_meter_area.set_content_height(responsive_value(9, 14, layout_height)) - monitor_panel.set_spacing(responsive_value(1, 4, layout_height)) + right_column.set_spacing( + responsive_value(COMPACT_UTILITY_PANE_SPACING, ROOMY_UTILITY_PANE_SPACING, layout_height) + ) + right_column.set_margin_top( + responsive_value(COMPACT_UTILITY_PANE_MARGIN_TOP, ROOMY_UTILITY_PANE_MARGIN_TOP, layout_height) + ) + right_column.set_margin_bottom( + responsive_value( + COMPACT_UTILITY_PANE_MARGIN_BOTTOM, + ROOMY_UTILITY_PANE_MARGIN_BOTTOM, + layout_height, + ) + ) + preset_section.set_spacing( + responsive_value(COMPACT_UTILITY_SECTION_SPACING, ROOMY_UTILITY_SECTION_SPACING, layout_height) + ) + system_section.set_spacing( + responsive_value(COMPACT_UTILITY_SECTION_SPACING, ROOMY_UTILITY_SECTION_SPACING, layout_height) + ) + self.headroom_panel.set_spacing( + responsive_value(COMPACT_HEADROOM_PANEL_SPACING, ROOMY_HEADROOM_PANEL_SPACING, layout_height) + ) + self.headroom_meter_area.set_content_height( + responsive_value( + COMPACT_HEADROOM_METER_CONTENT_HEIGHT, + ROOMY_HEADROOM_METER_CONTENT_HEIGHT, + layout_height, + ) + ) + monitor_panel.set_spacing( + responsive_value(COMPACT_MONITOR_PANEL_SPACING, ROOMY_MONITOR_PANEL_SPACING, layout_height) + ) if compact: right_column.set_margin_start(18) @@ -789,14 +841,20 @@ def install_css(self) -> None: ) def show_about_dialog(self) -> None: - dialog = Adw.AboutDialog( - application_icon=APP_ICON_NAME, - application_name=APP_NAME, - developer_name="bhack", - developers=["bhack"], - issue_url="https://github.com/bhack/mini-eq/issues", - license_type=Gtk.License.GPL_3_0, - version=__version__, - website="https://github.com/bhack/mini-eq", - ) + dialog_properties = { + "application_icon": APP_ICON_NAME, + "application_name": APP_NAME, + "developer_name": "bhack", + "developers": ["bhack"], + "issue_url": "https://github.com/bhack/mini-eq/issues", + "license_type": Gtk.License.GPL_3_0, + "version": __version__, + "website": "https://github.com/bhack/mini-eq", + } + release_notes = about_release_notes(__version__) + if release_notes is not None: + dialog_properties["release_notes"] = release_notes.markup + dialog_properties["release_notes_version"] = release_notes.version + + dialog = Adw.AboutDialog(**dialog_properties) dialog.present(self) diff --git a/src/mini_eq/window_presets.py b/src/mini_eq/window_presets.py index 42be657..a61e241 100644 --- a/src/mini_eq/window_presets.py +++ b/src/mini_eq/window_presets.py @@ -37,13 +37,17 @@ DELETED_PRESET_LABEL_PREFIX = "Unsaved copy: " -def imported_apo_curve_label(path: str) -> str: - preset_name = sanitize_preset_name(Path(path).stem) +def imported_apo_curve_label_for_name(name: str) -> str: + preset_name = sanitize_preset_name(name) if preset_name: return f"{APO_IMPORT_LABEL_PREFIX}{preset_name}" return "Imported APO" +def imported_apo_curve_label(path: str) -> str: + return imported_apo_curve_label_for_name(Path(path).stem) + + @dataclass(frozen=True) class PresetPanelUiState: preset_state_text: str diff --git a/src/mini_eq/window_state.py b/src/mini_eq/window_state.py new file mode 100644 index 0000000..6e2ddcf --- /dev/null +++ b/src/mini_eq/window_state.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("Gtk", "4.0") + +from gi.repository import Gio, Gtk + +from .desktop_integration import APP_ID + +WINDOW_STATE_SCHEMA_ID = APP_ID +WINDOW_WIDTH_KEY = "window-width" +WINDOW_HEIGHT_KEY = "window-height" +WINDOW_MAXIMIZED_KEY = "window-maximized" + + +def window_state_schema_available() -> bool: + schema_source = Gio.SettingsSchemaSource.get_default() + return schema_source is not None and schema_source.lookup(WINDOW_STATE_SCHEMA_ID, True) is not None + + +def create_window_state_settings() -> Gio.Settings | None: + if not window_state_schema_available(): + return None + + return Gio.Settings.new(WINDOW_STATE_SCHEMA_ID) + + +def bind_window_state(window: Gtk.Window) -> Gio.Settings | None: + settings = create_window_state_settings() + if settings is None: + return None + + flags = Gio.SettingsBindFlags.DEFAULT + settings.bind(WINDOW_WIDTH_KEY, window, "default-width", flags) + settings.bind(WINDOW_HEIGHT_KEY, window, "default-height", flags) + settings.bind(WINDOW_MAXIMIZED_KEY, window, "maximized", flags) + return settings diff --git a/tests/test_check_autoeq_live.py b/tests/test_check_autoeq_live.py new file mode 100644 index 0000000..5601f66 --- /dev/null +++ b/tests/test_check_autoeq_live.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json + +import pytest + +from mini_eq import autoeq +from tools import check_autoeq_live + + +def make_entry( + name: str = "First AutoEQ Profile", + source: str = "oratory1990", + form: str = "over-ear", + rig: str = "", +) -> autoeq.AutoEqEntry: + return autoeq.AutoEqEntry(name=name, source=source, form=form, rig=rig) + + +def test_select_probe_entry_prefers_exact_profile_identity() -> None: + entries = [ + make_entry(name="First AutoEQ Profile", source="other"), + make_entry(), + ] + + entry = check_autoeq_live.select_probe_entry( + entries, + name="First AutoEQ Profile", + source="oratory1990", + form="over-ear", + rig=None, + ) + + assert entry.source == "oratory1990" + + +def test_select_probe_entry_defaults_to_first_parsed_profile() -> None: + entries = [ + make_entry(name="First"), + make_entry(name="Second"), + ] + + entry = check_autoeq_live.select_probe_entry(entries, name=None, source=None, form=None, rig=None) + + assert entry.name == "First" + + +def test_validate_targets_rejects_changed_target_shape() -> None: + with pytest.raises(RuntimeError, match="labeled targets"): + check_autoeq_live.validate_targets([{"name": "Missing label"}]) + + +def test_parse_generated_apo_rejects_non_apo_text() -> None: + with pytest.raises(RuntimeError, match="does not look like an Equalizer APO preset"): + check_autoeq_live.parse_generated_apo("not an apo preset") + + +def test_live_check_downloads_equalizes_and_imports_apo(monkeypatch: pytest.MonkeyPatch) -> None: + entries_text = json.dumps( + { + "First AutoEQ Profile": [ + {"source": "oratory1990", "form": "over-ear"}, + ] + } + ) + targets_text = json.dumps( + [ + { + "label": "Harman over-ear 2018", + "recommended": [{"source": "oratory1990", "form": "over-ear"}], + "bassBoost": {"fc": 105, "q": 0.7, "gain": 6}, + } + ] + ) + requested_urls: list[str] = [] + equalize_bodies: list[dict[str, object]] = [] + + def fetch_text(url: str, *, timeout: int) -> str: + requested_urls.append(url) + assert timeout == 7 + if url == autoeq.AUTOEQ_APP_ENTRIES_URL: + return entries_text + if url == autoeq.AUTOEQ_APP_TARGETS_URL: + return targets_text + raise AssertionError(f"unexpected URL: {url}") + + def post_json(url: str, body: dict[str, object], *, timeout: int) -> dict[str, object]: + assert url == autoeq.AUTOEQ_APP_EQUALIZE_URL + assert timeout == 7 + equalize_bodies.append(body) + return { + "parametric_eq": { + "preamp": -5.5, + "filters": [ + {"type": "LOW_SHELF", "fc": 105.0, "gain": 3.0, "q": 0.7}, + {"type": "PEAKING", "fc": 1000.0, "gain": -2.0, "q": 1.2}, + ], + } + } + + monkeypatch.setattr(check_autoeq_live.autoeq, "fetch_text", fetch_text) + monkeypatch.setattr(check_autoeq_live.autoeq, "post_json", post_json) + + result = check_autoeq_live.check_autoeq_live(timeout=7) + + assert requested_urls == [autoeq.AUTOEQ_APP_ENTRIES_URL, autoeq.AUTOEQ_APP_TARGETS_URL] + assert equalize_bodies[0]["target"] == "Harman over-ear 2018" + assert result.entry == make_entry() + assert result.entry_count == 1 + assert result.target_count == 1 + assert result.band_count == 2 + assert result.preamp_db == -5.5 diff --git a/tests/test_flatpak_manifest.py b/tests/test_flatpak_manifest.py new file mode 100644 index 0000000..2129648 --- /dev/null +++ b/tests/test_flatpak_manifest.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_flatpak_manifest_installs_gsettings_schema() -> None: + manifest = (ROOT / "io.github.bhack.mini-eq.yaml").read_text(encoding="utf-8") + + assert "src/mini_eq/assets/schemas/io.github.bhack.mini-eq.gschema.xml" in manifest + assert "glib-compile-schemas --strict ${FLATPAK_DEST}/share/glib-2.0/schemas" in manifest diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index ceca326..d042538 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -79,6 +79,7 @@ def test_ci_scope_treats_release_workflow_and_release_gate_tools_as_tested_chang ci_yml = workflow_text("ci.yml") assert ".github/workflows/*.yml)" in ci_yml + assert "tools/check_autoeq_live.py" in ci_yml assert "tools/release_gates.py" in ci_yml assert "tools/release_runtime_gate.py" in ci_yml assert "tools/check_headless_pipewire_runtime.py" in ci_yml @@ -87,6 +88,17 @@ def test_ci_scope_treats_release_workflow_and_release_gate_tools_as_tested_chang assert "tools/run_live_ui_runtime_smoke_ci.sh" in ci_yml +def test_autoeq_live_workflow_is_scheduled_and_manual_only() -> None: + workflow = workflow_text("autoeq-live.yml") + + assert "workflow_dispatch:" in workflow + assert "schedule:" in workflow + assert "cron:" in workflow + assert "pull_request:" not in workflow + assert "push:" not in workflow + assert "tools/check_autoeq_live.py" in workflow + + def test_headless_pipewire_runtime_smoke_is_optional_ci_gate_without_nested_gnome() -> None: ci_yml = workflow_text("ci.yml") job = ci_yml.partition("headless-pipewire-runtime-smoke:")[2].partition("live-ui-runtime-smoke:")[0] diff --git a/tests/test_mini_eq_autoeq.py b/tests/test_mini_eq_autoeq.py new file mode 100644 index 0000000..6230e37 --- /dev/null +++ b/tests/test_mini_eq_autoeq.py @@ -0,0 +1,878 @@ +from __future__ import annotations + +import json + +import pytest + +from tests._mini_eq_imports import import_mini_eq_module + +autoeq = import_mini_eq_module("autoeq") +window_autoeq = import_mini_eq_module("window_autoeq") + + +def make_entry( + name: str = "Example", + source: str = "Source", + form: str = "in-ear", + rig: str = "", +) -> autoeq.AutoEqEntry: + return autoeq.AutoEqEntry(name=name, source=source, form=form, rig=rig) + + +def use_autoeq_cache(monkeypatch, tmp_path): + cache_dir = tmp_path / "autoeq" + (cache_dir / autoeq.AUTOEQ_PRESET_DIR).mkdir(parents=True) + monkeypatch.setattr(autoeq, "autoeq_cache_dir", lambda: cache_dir) + return cache_dir + + +def test_parse_autoeq_app_entries_deduplicates_profiles() -> None: + text = json.dumps( + { + "Example": [ + {"source": "Source", "form": "in-ear"}, + {"source": "Source", "form": "in-ear"}, + ], + "Other": [ + {"source": "Source", "form": "over-ear", "rig": "Rig"}, + {"source": "", "form": "over-ear"}, + ], + "Broken": [None], + } + ) + + assert autoeq.parse_autoeq_app_entries(text) == [ + make_entry(), + make_entry(name="Other", form="over-ear", rig="Rig"), + ] + + +def test_parse_autoeq_app_entries_rejects_changed_top_level_shape() -> None: + with pytest.raises(RuntimeError, match="AutoEq profile list does not have the expected shape"): + autoeq.parse_autoeq_app_entries("[]") + + +def test_search_autoeq_entries_matches_name_source_and_rig() -> None: + entries = [ + make_entry(name="Example Reference Headphone", source="oratory1990", form="over-ear"), + make_entry(name="Anker Soundcore", source="Other", form="over-ear"), + make_entry(name="Sennheiser HD 650", source="Rtings", form="over-ear", rig="HATS"), + ] + + assert autoeq.search_autoeq_entries(entries, "reference")[0].name == "Example Reference Headphone" + assert autoeq.search_autoeq_entries(entries, "hd hats")[0].name == "Sennheiser HD 650" + assert autoeq.search_autoeq_entries(entries, "missing") == [] + + +def test_post_json_rejects_changed_response_shape(monkeypatch) -> None: + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> bytes: + return b"[]" + + monkeypatch.setattr(autoeq, "urlopen", lambda _request, timeout: FakeResponse()) + + with pytest.raises(RuntimeError, match="AutoEq response does not have the expected shape"): + autoeq.post_json(autoeq.AUTOEQ_APP_EQUALIZE_URL, {}) + + +def test_load_autoeq_entries_uses_cache_without_refresh(monkeypatch, tmp_path) -> None: + cache_path = tmp_path / "entries.json" + cache_path.write_text(json.dumps({"Cached": [{"source": "Source", "form": "in-ear"}]}), encoding="utf-8") + monkeypatch.setattr(autoeq, "autoeq_entries_cache_path", lambda: cache_path) + monkeypatch.setattr(autoeq, "fetch_text", lambda _url: (_ for _ in ()).throw(AssertionError("network used"))) + + assert autoeq.load_autoeq_entries() == [make_entry(name="Cached")] + + +def test_autoeq_equalize_body_uses_preferred_target_bass_and_sample_rate() -> None: + entry = make_entry(name="Example Reference Headphone", source="oratory1990", form="over-ear", rig="GRAS 45BC-10") + targets = [ + { + "label": "Harman over-ear 2018", + "recommended": [{"source": "oratory1990", "form": "over-ear"}], + "bassBoost": {"fc": 105, "q": 0.7, "gain": 6}, + } + ] + + body = autoeq.autoeq_equalize_body(entry, targets) + + assert body["target"] == "Harman over-ear 2018" + assert body["bass_boost_gain"] == 6.0 + assert body["fs"] == 48000 + assert body["name"] == "Example Reference Headphone" + assert body["source"] == "oratory1990" + assert body["rig"] == "GRAS 45BC-10" + + +def test_download_autoeq_preset_writes_equalizer_apo_text(monkeypatch, tmp_path) -> None: + entry = make_entry() + bodies: list[dict[str, object]] = [] + use_autoeq_cache(monkeypatch, tmp_path) + + monkeypatch.setattr( + autoeq, + "load_autoeq_targets_data", + lambda *, refresh=False: [ + { + "label": "Target", + "recommended": [{"source": "Source", "form": "in-ear"}], + "bassBoost": {"fc": 105, "q": 0.7, "gain": 6}, + } + ], + ) + + def post_json(url: str, body: dict[str, object]) -> dict[str, object]: + bodies.append(body) + assert url == autoeq.AUTOEQ_APP_EQUALIZE_URL + return { + "parametric_eq": { + "preamp": -4.62, + "filters": [{"type": "LOW_SHELF", "fc": 105.0, "gain": 3.8, "q": 0.7}], + } + } + + monkeypatch.setattr(autoeq, "post_json", post_json) + + path = autoeq.download_autoeq_preset(entry) + + assert path.is_file() + assert path.name.startswith("AutoEq-") + assert path.read_text(encoding="utf-8") == ( + "# AutoEq target: Target\nPreamp: -4.62 dB\nFilter 1: ON LSC Fc 105.0 Hz Gain 3.8 dB Q 0.70\n" + ) + assert bodies[0]["target"] == "Target" + + +def test_download_autoeq_app_preset_rejects_changed_preset_shape(monkeypatch) -> None: + monkeypatch.setattr(autoeq, "load_autoeq_targets_data", lambda *, refresh=False: []) + monkeypatch.setattr(autoeq, "post_json", lambda _url, _body: {"parametric_eq": {"filters": []}}) + + with pytest.raises(RuntimeError, match="AutoEq response did not include a parametric EQ preset"): + autoeq.download_autoeq_app_preset(make_entry()) + + +def test_download_autoeq_preset_reuses_cached_file(monkeypatch, tmp_path) -> None: + entry = make_entry() + use_autoeq_cache(monkeypatch, tmp_path) + path = autoeq.autoeq_download_path(entry) + path.write_text("Preamp: -1.0 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + monkeypatch.setattr(autoeq, "load_autoeq_targets_data", lambda *, refresh=False: []) + monkeypatch.setattr(autoeq, "post_json", lambda _url, _body: (_ for _ in ()).throw(AssertionError("network used"))) + + assert autoeq.download_autoeq_preset(entry) == path + + +def test_download_autoeq_preset_info_reads_cached_target_without_network(monkeypatch, tmp_path) -> None: + entry = make_entry() + use_autoeq_cache(monkeypatch, tmp_path) + path = autoeq.autoeq_download_path(entry) + path.write_text( + "# AutoEq target: Target\nPreamp: -1.0 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", + encoding="utf-8", + ) + monkeypatch.setattr(autoeq, "post_json", lambda _url, _body: (_ for _ in ()).throw(AssertionError("network used"))) + + preset = autoeq.download_autoeq_preset_info(entry) + + assert preset.path == path + assert preset.target_label == "Target" + + +def test_download_autoeq_preset_info_computes_target_for_older_cached_file(monkeypatch, tmp_path) -> None: + entry = make_entry() + use_autoeq_cache(monkeypatch, tmp_path) + path = autoeq.autoeq_download_path(entry) + path.write_text("Preamp: -1.0 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + monkeypatch.setattr( + autoeq, + "load_autoeq_targets_data", + lambda *, refresh=False: [ + { + "label": "Target", + "recommended": [{"source": "Source", "form": "in-ear"}], + "bassBoost": {"fc": 105, "q": 0.7, "gain": 6}, + } + ], + ) + monkeypatch.setattr(autoeq, "post_json", lambda _url, _body: (_ for _ in ()).throw(AssertionError("network used"))) + + preset = autoeq.download_autoeq_preset_info(entry) + + assert preset.path == path + assert preset.target_label == "Target" + + +def test_download_autoeq_preset_info_keeps_older_cached_file_when_target_lookup_fails(monkeypatch, tmp_path) -> None: + entry = make_entry() + use_autoeq_cache(monkeypatch, tmp_path) + path = autoeq.autoeq_download_path(entry) + path.write_text("Preamp: -1.0 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + monkeypatch.setattr( + autoeq, + "load_autoeq_targets_data", + lambda *, refresh=False: (_ for _ in ()).throw(RuntimeError("could not download AutoEq data")), + ) + monkeypatch.setattr(autoeq, "post_json", lambda _url, _body: (_ for _ in ()).throw(AssertionError("network used"))) + + preset = autoeq.download_autoeq_preset_info(entry) + + assert preset.path == path + assert preset.target_label == autoeq.AUTOEQ_UNKNOWN_TARGET_LABEL + + +class FakeLabel: + def __init__(self) -> None: + self.text = "" + + def set_text(self, text: str) -> None: + self.text = text + + +class FakeArea: + def __init__(self) -> None: + self.draw_count = 0 + self.accessible_updates: list[tuple[list[object], list[str]]] = [] + + def queue_draw(self) -> None: + self.draw_count += 1 + + def update_property(self, properties: list[object], values: list[str]) -> None: + self.accessible_updates.append((properties, values)) + + +class FakeButton: + def __init__(self) -> None: + self.sensitive = True + self.focused = False + self.text = "" + + def set_sensitive(self, sensitive: bool) -> None: + self.sensitive = sensitive + + def grab_focus(self) -> None: + self.focused = True + + def get_text(self) -> str: + return self.text + + +class FakeSpinner(FakeButton): + def __init__(self) -> None: + super().__init__() + self.visible = False + self.started = False + + def set_visible(self, visible: bool) -> None: + self.visible = visible + + def start(self) -> None: + self.started = True + + def stop(self) -> None: + self.started = False + + +class FakeDialog: + def __init__(self) -> None: + self.can_close_history: list[bool] = [] + self.force_closed = False + self.visible = True + self.handlers: list[tuple[str, object]] = [] + + def connect(self, signal_name: str, callback) -> int: + self.handlers.append((signal_name, callback)) + return len(self.handlers) + + def get_visible(self) -> bool: + return self.visible + + def set_can_close(self, can_close: bool) -> None: + self.can_close_history.append(can_close) + + def force_close(self) -> None: + self.force_closed = True + self.visible = False + + +class FakeRow: + def __init__(self, entry: autoeq.AutoEqEntry) -> None: + self.autoeq_entry = entry + + +class FakePlaceholderRow: + def __init__(self, message: str) -> None: + self.message = message + + +class FakeActionRow: + def __init__(self) -> None: + self.title = "" + self.subtitle = "" + self.tooltip = "" + self.title_lines = 0 + self.subtitle_lines = 0 + self.selectable = False + self.activatable = False + + def set_title(self, title: str) -> None: + self.title = title + + def set_title_lines(self, title_lines: int) -> None: + self.title_lines = title_lines + + def set_subtitle(self, subtitle: str) -> None: + self.subtitle = subtitle + + def set_subtitle_lines(self, subtitle_lines: int) -> None: + self.subtitle_lines = subtitle_lines + + def set_tooltip_text(self, tooltip: str) -> None: + self.tooltip = tooltip + + def set_selectable(self, selectable: bool) -> None: + self.selectable = selectable + + def set_activatable(self, activatable: bool) -> None: + self.activatable = activatable + + +class FakeResultsList(FakeButton): + def __init__(self, row: FakeRow | None) -> None: + super().__init__() + self.row = row + self.rows = [] + + def get_selected_row(self) -> FakeRow | None: + return self.row + + def get_sensitive(self) -> bool: + return self.sensitive + + def get_row_at_index(self, index: int): + return self.rows[index] if index < len(self.rows) else None + + def append(self, row) -> None: + self.rows.append(row) + + def remove(self, row) -> None: + self.rows.remove(row) + + +def cached_autoeq_import_window(tmp_path, entry: autoeq.AutoEqEntry): + import_window = AutoEqImportWindow(entry) + path = tmp_path / "example.txt" + path.write_text("Preamp: -1 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + import_window.autoeq_selected_entry = entry + import_window.autoeq_preview_path = path + import_window.autoeq_preview_target_label = "Target" + return import_window, path + + +class AutoEqPreviewWindow(window_autoeq.MiniEqWindowAutoEqMixin): + def __init__(self) -> None: + self.autoeq_selected_entry = None + self.autoeq_preview_path = None + self.autoeq_preview_preamp_db = None + self.autoeq_preview_target_label = None + self.autoeq_preview_bands = [] + self.autoeq_preview_error = None + self.autoeq_preview_loading = False + self.autoeq_preview_source_id = 0 + self.autoeq_preview_request_id = 0 + self.autoeq_dialog_closed = False + self.autoeq_preview_title = FakeLabel() + self.autoeq_preview_count_label = FakeLabel() + self.autoeq_preview_detail = FakeLabel() + self.autoeq_preview_area = FakeArea() + + +class AutoEqImportWindow(window_autoeq.MiniEqWindowAutoEqMixin): + def __init__(self, entry: autoeq.AutoEqEntry) -> None: + self.autoeq_dialog = FakeDialog() + self.autoeq_cancel_button = FakeButton() + self.autoeq_status_label = FakeLabel() + self.autoeq_spinner = FakeSpinner() + self.autoeq_refresh_button = FakeButton() + self.autoeq_search_entry = FakeButton() + self.autoeq_results_list = FakeResultsList(FakeRow(entry)) + self.autoeq_import_button = FakeButton() + self.autoeq_selected_entry = None + self.autoeq_preview_path = None + self.autoeq_entries: list[autoeq.AutoEqEntry] = [] + self.autoeq_profiles_request_id = 0 + self.autoeq_preview_title = FakeLabel() + self.autoeq_preview_count_label = FakeLabel() + self.autoeq_preview_detail = FakeLabel() + self.autoeq_preview_area = FakeArea() + self.autoeq_preview_preamp_db = None + self.autoeq_preview_target_label = None + self.autoeq_preview_bands = [] + self.autoeq_preview_error = None + self.autoeq_preview_loading = False + self.autoeq_preview_source_id = 0 + self.autoeq_preview_request_id = 0 + self.autoeq_import_in_progress = False + self.autoeq_dialog_closed = False + self.autoeq_import_request_id = 0 + self.imported: list[tuple[str, str | None]] = [] + self.statuses: list[str] = [] + self.placeholders: list[str] = [] + + def import_apo_preset_path(self, path: str, *, imported_name: str | None = None) -> int: + self.imported.append((path, imported_name)) + return 10 + + def set_status(self, status: str) -> None: + self.statuses.append(status) + + def show_autoeq_placeholder(self, message: str) -> None: + self.placeholders.append(message) + self.autoeq_results_list.append(FakePlaceholderRow(message)) + + +def test_autoeq_result_row_escapes_markup_text(monkeypatch) -> None: + entry = make_entry( + name="crinacle - Bruel & Kjaer 4620", + source="crinacle", + form="over-ear", + rig="Bruel & Kjaer 4620", + ) + import_window = AutoEqImportWindow(entry) + monkeypatch.setattr(window_autoeq.Adw, "ActionRow", FakeActionRow) + + row = import_window.make_autoeq_result_row(entry) + + assert row.title == "crinacle - Bruel & Kjaer 4620" + assert row.subtitle == "crinacle - Bruel & Kjaer 4620" + assert row.tooltip == "crinacle - Bruel & Kjaer 4620\ncrinacle - Bruel & Kjaer 4620" + assert row.autoeq_entry is entry + + +def test_preview_selection_is_debounced_and_stale_requests_are_ignored(monkeypatch) -> None: + scheduled: list[tuple[int, object, tuple[object, ...]]] = [] + removed: list[int] = [] + started: list[tuple[autoeq.AutoEqEntry, int | None]] = [] + first = make_entry(name="First") + second = make_entry(name="Second") + preview_window = AutoEqPreviewWindow() + + def timeout_add(delay_ms, callback, *args): + scheduled.append((delay_ms, callback, args)) + return len(scheduled) + + def start_preview(self, entry, *, request_id=None): + started.append((entry, request_id)) + + monkeypatch.setattr(window_autoeq.GLib, "timeout_add", timeout_add) + monkeypatch.setattr(window_autoeq, "destroy_glib_source", lambda source_id: removed.append(source_id)) + monkeypatch.setattr(window_autoeq.MiniEqWindowAutoEqMixin, "start_autoeq_preview_load", start_preview) + + preview_window.schedule_autoeq_preview_load(first) + preview_window.schedule_autoeq_preview_load(second) + + assert removed == [1] + assert preview_window.autoeq_selected_entry == second + assert preview_window.autoeq_preview_count_label.text == "Preview" + assert scheduled[0][0] == window_autoeq.AUTOEQ_PREVIEW_DEBOUNCE_MS + + assert scheduled[0][1](*scheduled[0][2]) is False + assert started == [] + + assert scheduled[1][1](*scheduled[1][2]) is False + assert started == [(second, 2)] + + +def test_autoeq_dialog_focuses_search_entry() -> None: + entry = make_entry() + import_window = AutoEqImportWindow(entry) + + assert import_window.focus_autoeq_search_entry() is False + + assert import_window.autoeq_search_entry.focused is True + + +def test_finish_profiles_load_ignores_stale_request(monkeypatch) -> None: + idle_calls: list[object] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + import_window.autoeq_profiles_request_id = 2 + + monkeypatch.setattr(window_autoeq.GLib, "idle_add", lambda callback, *args: idle_calls.append((callback, args))) + + assert import_window.finish_autoeq_profiles_load(1, [entry], None) is False + + assert import_window.autoeq_entries == [] + assert import_window.autoeq_status_label.text == "" + assert idle_calls == [] + + +def test_cleanup_autoeq_dialog_invalidates_pending_work(monkeypatch) -> None: + removed: list[int] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + import_window.autoeq_profiles_request_id = 3 + import_window.autoeq_import_request_id = 4 + import_window.autoeq_preview_request_id = 5 + import_window.autoeq_preview_source_id = 88 + import_window.autoeq_import_in_progress = True + + monkeypatch.setattr(window_autoeq, "destroy_glib_source", lambda source_id: removed.append(source_id)) + + import_window.cleanup_autoeq_dialog() + + assert removed == [88] + assert import_window.autoeq_dialog_closed is True + assert import_window.autoeq_dialog is None + assert import_window.autoeq_import_in_progress is False + assert import_window.autoeq_preview_source_id == 0 + assert import_window.autoeq_profiles_request_id == 4 + assert import_window.autoeq_import_request_id == 5 + assert import_window.autoeq_preview_request_id == 6 + + +def test_closed_dialog_signal_runs_autoeq_cleanup(monkeypatch) -> None: + removed: list[int] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + dialog = import_window.autoeq_dialog + import_window.autoeq_preview_source_id = 88 + + monkeypatch.setattr(window_autoeq, "destroy_glib_source", lambda source_id: removed.append(source_id)) + + import_window.on_autoeq_dialog_closed(dialog) + + assert removed == [88] + assert import_window.autoeq_dialog_closed is True + assert import_window.autoeq_dialog is None + + +def test_finish_profiles_load_ignores_closed_dialog(monkeypatch) -> None: + idle_calls: list[object] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + import_window.autoeq_profiles_request_id = 1 + import_window.autoeq_dialog_closed = True + + monkeypatch.setattr(window_autoeq.GLib, "idle_add", lambda callback, *args: idle_calls.append((callback, args))) + + assert import_window.finish_autoeq_profiles_load(1, [entry], None) is False + + assert import_window.autoeq_entries == [] + assert import_window.autoeq_status_label.text == "" + assert idle_calls == [] + + +def test_finish_profiles_load_refocuses_search_after_success(monkeypatch) -> None: + idle_calls: list[object] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + import_window.autoeq_profiles_request_id = 1 + + def idle_add(callback, *args): + idle_calls.append((callback, args)) + return len(idle_calls) + + monkeypatch.setattr(window_autoeq.GLib, "idle_add", idle_add) + + assert import_window.finish_autoeq_profiles_load(1, [entry], None) is False + + assert import_window.autoeq_entries == [entry] + assert import_window.autoeq_status_label.text == "Search by headphone model" + assert idle_calls == [(import_window.focus_autoeq_search_entry, ())] + + +def test_profiles_load_failure_stays_inside_dialog(monkeypatch) -> None: + idle_calls: list[tuple[object, tuple[object, ...]]] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + + class FakeThread: + def __init__(self, target, *, daemon) -> None: + self.target = target + self.daemon = daemon + + def start(self) -> None: + self.target() + + def idle_add(callback, *args): + idle_calls.append((callback, args)) + return len(idle_calls) + + def load_autoeq_entries(*, refresh): + raise RuntimeError("AutoEq profile list is not valid JSON") + + monkeypatch.setattr(window_autoeq.threading, "Thread", FakeThread) + monkeypatch.setattr(window_autoeq.GLib, "idle_add", idle_add) + monkeypatch.setattr(window_autoeq, "load_autoeq_entries", load_autoeq_entries) + + import_window.start_autoeq_profiles_load(refresh=True) + assert idle_calls + + callback, args = idle_calls.pop(0) + assert callback(*args) is False + + assert import_window.autoeq_entries == [] + assert import_window.autoeq_status_label.text == "AutoEq profile list is not valid JSON" + assert import_window.autoeq_spinner.started is False + assert import_window.autoeq_refresh_button.sensitive is True + assert import_window.autoeq_dialog.force_closed is False + + +def test_search_entry_enter_imports_selected_profile_from_cache(tmp_path) -> None: + entry = make_entry() + import_window, path = cached_autoeq_import_window(tmp_path, entry) + + import_window.on_autoeq_search_entry_activated(import_window.autoeq_search_entry) + + assert import_window.imported == [(str(path), "Example")] + assert import_window.autoeq_dialog.force_closed is True + assert import_window.autoeq_import_request_id == 1 + + +def test_results_enter_imports_selected_profile_from_cache(tmp_path) -> None: + entry = make_entry() + import_window, path = cached_autoeq_import_window(tmp_path, entry) + + handled = import_window.on_autoeq_results_key_pressed( + None, + window_autoeq.Gdk.KEY_Return, + 0, + window_autoeq.Gdk.ModifierType(0), + ) + + assert handled is True + assert import_window.imported == [(str(path), "Example")] + assert import_window.autoeq_dialog.force_closed is True + assert import_window.autoeq_import_request_id == 1 + + +def test_results_non_enter_key_does_not_import(tmp_path) -> None: + entry = make_entry() + import_window, _path = cached_autoeq_import_window(tmp_path, entry) + + handled = import_window.on_autoeq_results_key_pressed( + None, + window_autoeq.Gdk.KEY_space, + 0, + window_autoeq.Gdk.ModifierType(0), + ) + + assert handled is False + assert import_window.imported == [] + + +def test_preview_accessible_description_tracks_selection_and_result(monkeypatch) -> None: + scheduled: list[tuple[int, object, tuple[object, ...]]] = [] + entry = make_entry(rig="Rig") + preview_window = AutoEqPreviewWindow() + preview_window.autoeq_dialog = FakeDialog() + + def timeout_add(delay_ms, callback, *args): + scheduled.append((delay_ms, callback, args)) + return len(scheduled) + + monkeypatch.setattr(window_autoeq.GLib, "timeout_add", timeout_add) + + preview_window.clear_autoeq_preview() + preview_window.schedule_autoeq_preview_load(entry) + preview_window.finish_autoeq_preview_load(2, entry, "/tmp/example.txt", -1.5, [], "AutoEq in-ear", None) + + descriptions = [values[0] for _properties, values in preview_window.autoeq_preview_area.accessible_updates] + assert descriptions == [ + "No AutoEq profile selected", + "AutoEq curve preview for Example", + "AutoEq curve preview for Example: 0 filters, preamp -1.5 dB, target AutoEq in-ear", + ] + assert preview_window.autoeq_preview_detail.text == "Target: AutoEq in-ear - Preamp -1.5 dB - Source - Rig" + + +def test_preview_success_enables_import_after_target_is_visible(tmp_path) -> None: + entry = make_entry(rig="Rig") + import_window = AutoEqImportWindow(entry) + import_window.autoeq_selected_entry = entry + import_window.autoeq_preview_request_id = 1 + import_window.autoeq_import_button.set_sensitive(False) + path = tmp_path / "example.txt" + path.write_text("Preamp: -1 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + + assert import_window.finish_autoeq_preview_load(1, entry, str(path), -1.5, [], "AutoEq in-ear", None) is False + + assert import_window.autoeq_preview_detail.text == "Target: AutoEq in-ear - Preamp -1.5 dB - Source - Rig" + assert import_window.autoeq_import_button.sensitive is True + + +def test_preview_success_keeps_import_disabled_while_results_are_busy(tmp_path) -> None: + entry = make_entry(rig="Rig") + import_window = AutoEqImportWindow(entry) + import_window.autoeq_selected_entry = entry + import_window.autoeq_preview_request_id = 1 + import_window.autoeq_results_list.set_sensitive(False) + path = tmp_path / "example.txt" + path.write_text("Preamp: -1 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + + assert import_window.finish_autoeq_preview_load(1, entry, str(path), -1.5, [], "AutoEq in-ear", None) is False + + assert import_window.autoeq_preview_detail.text == "Target: AutoEq in-ear - Preamp -1.5 dB - Source - Rig" + assert import_window.autoeq_import_button.sensitive is False + + +def test_preview_chart_scale_keeps_small_curves_readable() -> None: + preview_window = AutoEqPreviewWindow() + + assert preview_window.autoeq_preview_db_limit([]) == 15.0 + assert preview_window.autoeq_preview_db_limit([2.0, -14.8]) == 15.0 + assert preview_window.autoeq_preview_db_limit([15.1]) == 20.0 + assert preview_window.autoeq_preview_db_limit([99.0]) == window_autoeq.GRAPH_DB_MAX + + +def test_preview_frequency_ticks_keep_import_preview_compact() -> None: + assert window_autoeq.AUTOEQ_PREVIEW_MAJOR_FREQ_TICKS == (20.0, 100.0, 1000.0, 10000.0, 20000.0) + assert window_autoeq.AUTOEQ_PREVIEW_MINOR_FREQ_TICKS == (50.0, 200.0, 500.0, 2000.0, 5000.0) + assert window_autoeq.AUTOEQ_PREVIEW_FREQ_TICKS == ( + 20.0, + 50.0, + 100.0, + 200.0, + 500.0, + 1000.0, + 2000.0, + 5000.0, + 10000.0, + 20000.0, + ) + + +def test_preview_generation_failure_stays_inside_dialog(monkeypatch) -> None: + idle_calls: list[tuple[object, tuple[object, ...]]] = [] + entry = make_entry() + preview_window = AutoEqPreviewWindow() + preview_window.autoeq_dialog = FakeDialog() + + class FakeThread: + def __init__(self, target, *, daemon) -> None: + self.target = target + self.daemon = daemon + + def start(self) -> None: + self.target() + + def idle_add(callback, *args): + idle_calls.append((callback, args)) + return len(idle_calls) + + def download_autoeq_preset_info(_entry): + raise RuntimeError("AutoEq response format changed") + + monkeypatch.setattr(window_autoeq.threading, "Thread", FakeThread) + monkeypatch.setattr(window_autoeq.GLib, "idle_add", idle_add) + monkeypatch.setattr(window_autoeq, "download_autoeq_preset_info", download_autoeq_preset_info) + + preview_window.start_autoeq_preview_load(entry) + assert idle_calls + + callback, args = idle_calls.pop(0) + assert callback(*args) is False + + assert preview_window.autoeq_preview_error == "AutoEq response format changed" + assert preview_window.autoeq_preview_count_label.text == "Unavailable" + assert preview_window.autoeq_preview_detail.text == "AutoEq response format changed" + assert preview_window.autoeq_preview_path is None + + +def test_import_waits_for_preview_target_before_applying(monkeypatch, tmp_path) -> None: + started: list[object] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + path = tmp_path / "example.txt" + path.write_text("Preamp: -1 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + import_window.autoeq_selected_entry = entry + import_window.autoeq_preview_path = path + import_window.autoeq_preview_target_label = None + + class FakeThread: + def __init__(self, target, *, daemon) -> None: + self.target = target + self.daemon = daemon + + def start(self) -> None: + started.append(self.target) + + monkeypatch.setattr(window_autoeq.threading, "Thread", FakeThread) + + import_window.on_autoeq_import_clicked(FakeButton()) + + assert started == [] + assert import_window.autoeq_import_in_progress is False + + +def test_import_applies_previewed_preset_without_downloading_again(monkeypatch, tmp_path) -> None: + started: list[object] = [] + entry = make_entry() + import_window = AutoEqImportWindow(entry) + path = tmp_path / "example.txt" + path.write_text("Preamp: -1 dB\nFilter 1: ON PK Fc 100 Hz Gain 1 dB Q 1\n", encoding="utf-8") + import_window.autoeq_selected_entry = entry + import_window.autoeq_preview_path = path + import_window.autoeq_preview_target_label = "Target" + + class FakeThread: + def __init__(self, target, *, daemon) -> None: + self.target = target + self.daemon = daemon + + def start(self) -> None: + started.append(self.target) + + monkeypatch.setattr(window_autoeq.threading, "Thread", FakeThread) + + import_window.on_autoeq_import_clicked(FakeButton()) + + assert started == [] + assert import_window.imported == [(str(path), "Example")] + assert import_window.autoeq_dialog.force_closed is True + + +def test_successful_import_force_closes_dialog_after_applying_preset() -> None: + entry = make_entry() + import_window = AutoEqImportWindow(entry) + import_window.set_autoeq_import_in_progress(True) + import_window.autoeq_import_request_id = 1 + + assert import_window.finish_autoeq_import(1, entry, "/tmp/example.txt", None) is False + + assert import_window.imported == [("/tmp/example.txt", "Example")] + assert import_window.statuses == ["Imported AutoEq Preset: Example"] + assert import_window.autoeq_import_in_progress is False + assert import_window.autoeq_dialog.force_closed is True + + +def test_finish_import_ignores_stale_request() -> None: + entry = make_entry() + import_window = AutoEqImportWindow(entry) + import_window.autoeq_import_request_id = 2 + + assert import_window.finish_autoeq_import(1, entry, "/tmp/example.txt", None) is False + + assert import_window.imported == [] + assert import_window.statuses == [] + assert import_window.autoeq_dialog.force_closed is False + + +def test_finish_preview_ignores_closed_dialog() -> None: + entry = make_entry() + preview_window = AutoEqPreviewWindow() + preview_window.autoeq_dialog = FakeDialog() + preview_window.autoeq_dialog_closed = True + preview_window.autoeq_selected_entry = entry + preview_window.autoeq_preview_request_id = 1 + + assert preview_window.finish_autoeq_preview_load(1, entry, "/tmp/example.txt", -1.0, [], "Target", None) is False + + assert preview_window.autoeq_preview_path is None + assert preview_window.autoeq_preview_count_label.text == "" diff --git a/tests/test_mini_eq_dbus_control.py b/tests/test_mini_eq_dbus_control.py index 3720ba1..f789798 100644 --- a/tests/test_mini_eq_dbus_control.py +++ b/tests/test_mini_eq_dbus_control.py @@ -98,7 +98,11 @@ def __init__(self, controller: FakeController) -> None: self.route_switch = FakeSwitch(controller.routed) self.loaded_presets: list[str] = [] self.update_count = 0 + self.current_curve_text = "Flat" + self.preset_state_text = "Preset" + self.output_preset_link = "Headphones" self.output_preset_auto_applied = False + self.existing_presets = {"Flat", "Headphones"} self.visible = True def sync_control_switches_from_controller(self, *, route: bool = True, eq: bool = True) -> None: @@ -148,10 +152,21 @@ def refresh_after_eq_state_changed( def load_library_preset(self, name: str) -> None: self.current_preset_name = name + self.current_curve_text = name + self.preset_state_text = "Preset" self.loaded_presets.append(name) def output_preset_link_name(self) -> str | None: - return "Headphones" + return self.output_preset_link + + def preset_panel_ui_state(self) -> SimpleNamespace: + return SimpleNamespace( + current_curve_text=self.current_curve_text, + preset_state_text=self.preset_state_text, + ) + + def preset_name_exists(self, preset_name: str) -> bool: + return preset_name in self.existing_presets def present(self) -> None: pass @@ -214,8 +229,13 @@ def test_dbus_control_state_contains_shell_summary() -> None: "eq_enabled": True, "routed": False, "preset_name": "Flat", + "curve_name": "Flat", + "curve_status": "preset", + "curve_label": "Flat", "output_sink": "alsa_output.test", "output_preset_name": "Headphones", + "output_preset_status": "different", + "output_preset_label": "Different - Headphones", "output_preset_auto_applied": False, "analyzer_enabled": False, "background_mode": True, @@ -225,6 +245,46 @@ def test_dbus_control_state_contains_shell_summary() -> None: } +def test_dbus_control_state_marks_modified_curve_for_shell() -> None: + control, _controller, window = make_control() + window.current_curve_text = "Flat" + window.preset_state_text = "Modified" + + state = {key: value.unpack() for key, value in control.state().items()} + + assert state["curve_name"] == "Flat" + assert state["curve_status"] == "modified" + assert state["curve_label"] == "Flat (modified)" + + +@pytest.mark.parametrize( + ("current_preset_name", "auto_applied", "existing_presets", "status", "label"), + [ + ("Headphones", True, {"Headphones"}, "applied", "Applied - Headphones"), + ("Headphones", False, {"Headphones"}, "modified", "Modified - Headphones"), + ("Flat", False, {"Flat", "Headphones"}, "different", "Different - Headphones"), + (None, False, {"Headphones"}, "linked", "Linked - Headphones"), + ("Flat", False, {"Flat"}, "missing", "Missing - Headphones"), + ], +) +def test_dbus_control_state_describes_output_preset_for_shell( + current_preset_name: str | None, + auto_applied: bool, + existing_presets: set[str], + status: str, + label: str, +) -> None: + control, _controller, window = make_control() + window.current_preset_name = current_preset_name + window.output_preset_auto_applied = auto_applied + window.existing_presets = existing_presets + + state = {key: value.unpack() for key, value in control.state().items()} + + assert state["output_preset_status"] == status + assert state["output_preset_label"] == label + + def test_dbus_control_compacts_analyzer_levels_for_shell_signal() -> None: control, _controller, window = make_control() window.analyzer_enabled = True diff --git a/tests/test_mini_eq_desktop_integration.py b/tests/test_mini_eq_desktop_integration.py index 30aa167..75bce3c 100644 --- a/tests/test_mini_eq_desktop_integration.py +++ b/tests/test_mini_eq_desktop_integration.py @@ -1,5 +1,10 @@ from __future__ import annotations +import shutil +import subprocess + +import pytest + from tests._mini_eq_imports import import_mini_eq_module desktop_integration = import_mini_eq_module("desktop_integration") @@ -35,3 +40,47 @@ def test_remove_legacy_raster_app_icons_only_removes_mini_eq_pngs(tmp_path) -> N assert not mini_eq_png.exists() assert other_png.exists() assert mini_eq_svg.exists() + + +def test_gsettings_schema_compiles(tmp_path) -> None: + glib_compile_schemas = shutil.which("glib-compile-schemas") + if glib_compile_schemas is None: + pytest.skip("glib-compile-schemas is not installed") + + schema_path = tmp_path / desktop_integration.APP_SCHEMA_NAME + schema_path.write_bytes(desktop_integration.APP_SCHEMA_SOURCE.read_bytes()) + + subprocess.run([glib_compile_schemas, "--strict", "--dry-run", str(tmp_path)], check=True) + + +def test_install_gsettings_schema_copies_package_schema(monkeypatch, tmp_path) -> None: + compiled_dirs = [] + monkeypatch.setattr(desktop_integration, "compile_gsettings_schemas", lambda path: compiled_dirs.append(path)) + + target = desktop_integration.install_gsettings_schema(tmp_path) + + assert target == tmp_path / desktop_integration.APP_SCHEMA_NAME + assert target.read_bytes() == desktop_integration.APP_SCHEMA_SOURCE.read_bytes() + assert compiled_dirs == [tmp_path] + + +def test_compile_gsettings_schemas_raises_when_compiler_fails(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(desktop_integration.shutil, "which", lambda _name: "/usr/bin/glib-compile-schemas") + + def run(_command, *, check, capture_output, text): + assert check is False + assert capture_output is True + assert text is True + return subprocess.CompletedProcess(_command, 1, stdout="", stderr="schema failed") + + monkeypatch.setattr(desktop_integration.subprocess, "run", run) + + with pytest.raises(RuntimeError, match=f"could not compile GSettings schemas in {tmp_path}: schema failed"): + desktop_integration.compile_gsettings_schemas(tmp_path) + + +def test_compile_gsettings_schemas_noops_without_compiler(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(desktop_integration.shutil, "which", lambda _name: None) + monkeypatch.setattr(desktop_integration.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("compiler used")) + + desktop_integration.compile_gsettings_schemas(tmp_path) diff --git a/tests/test_mini_eq_release_notes.py b/tests/test_mini_eq_release_notes.py new file mode 100644 index 0000000..9540673 --- /dev/null +++ b/tests/test_mini_eq_release_notes.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pathlib import Path + +from mini_eq import __version__ +from tests._mini_eq_imports import import_mini_eq_module + +release_notes = import_mini_eq_module("release_notes") + + +def write_metainfo(path: Path) -> None: + path.write_text( + """ + + + +
    • Fix & tune output handling.
    • Add presets.
    +
    + +

    Older release.

    +
    +
    +
    +""", + encoding="utf-8", + ) + + +def test_release_notes_from_metainfo_extracts_matching_release_markup(tmp_path: Path) -> None: + path = tmp_path / "io.github.bhack.mini-eq.metainfo.xml" + write_metainfo(path) + + notes = release_notes.release_notes_from_metainfo(path, "1.2.3") + + assert notes == release_notes.AboutReleaseNotes( + version="1.2.3", + markup="
    • Fix & tune output handling.
    • Add presets.
    ", + ) + + +def test_release_notes_from_metainfo_ignores_missing_version(tmp_path: Path) -> None: + path = tmp_path / "io.github.bhack.mini-eq.metainfo.xml" + write_metainfo(path) + + assert release_notes.release_notes_from_metainfo(path, "9.9.9") is None + + +def test_current_appstream_release_notes_are_available() -> None: + notes = release_notes.about_release_notes(__version__) + + assert notes is not None + assert notes.version == __version__ + assert notes.markup.startswith("<") diff --git a/tests/test_mini_eq_window.py b/tests/test_mini_eq_window.py index 7ca54ff..a55c947 100644 --- a/tests/test_mini_eq_window.py +++ b/tests/test_mini_eq_window.py @@ -4,7 +4,9 @@ from tests._mini_eq_imports import import_mini_eq_module +release_notes = import_mini_eq_module("release_notes") window = import_mini_eq_module("window") +window_layout = import_mini_eq_module("window_layout") class FakeSwitch: @@ -60,6 +62,30 @@ def open_finish(self, _result: object) -> FakeFile: return FakeFile(self.path) +class FakeUtilityPaneButton: + def __init__(self, *, visible: bool) -> None: + self.visible = visible + self.active = False + + def get_visible(self) -> bool: + return self.visible + + def set_active(self, active: bool) -> None: + self.active = active + + +class FakeAboutDialog: + instances: list[FakeAboutDialog] = [] + + def __init__(self, **properties: object) -> None: + self.properties = properties + self.presented_to = None + self.instances.append(self) + + def present(self, parent: object) -> None: + self.presented_to = parent + + def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None: fake_window.sync_control_switches_from_controller = MethodType( window.MiniEqWindow.sync_control_switches_from_controller, @@ -79,6 +105,45 @@ def bind_control_refresh_methods(fake_window: SimpleNamespace) -> None: ) +def test_about_dialog_includes_current_release_notes(monkeypatch) -> None: + FakeAboutDialog.instances = [] + fake_window = SimpleNamespace() + notes = release_notes.AboutReleaseNotes("1.2.3", "

    Changed.

    ") + monkeypatch.setattr(window_layout.Adw, "AboutDialog", FakeAboutDialog) + monkeypatch.setattr(window_layout, "about_release_notes", lambda version: notes) + + window_layout.MiniEqWindowLayoutMixin.show_about_dialog(fake_window) + + dialog = FakeAboutDialog.instances[0] + assert dialog.properties["release_notes"] == "

    Changed.

    " + assert dialog.properties["release_notes_version"] == "1.2.3" + assert dialog.properties["version"] == window_layout.__version__ + assert dialog.presented_to is fake_window + + +def test_visual_layout_height_uses_startup_height_before_first_allocation() -> None: + fake_window = SimpleNamespace( + initial_layout_height=720, + default_min_window_height=600, + compact_min_window_height=600, + get_allocated_height=lambda: 0, + ) + + assert window_layout.visual_layout_height(fake_window, None) == 720 + + +def test_visual_layout_height_prefers_real_allocation() -> None: + fake_window = SimpleNamespace( + initial_layout_height=720, + default_min_window_height=600, + compact_min_window_height=600, + get_allocated_height=lambda: 840, + ) + + assert window_layout.visual_layout_height(fake_window, None) == 840 + assert window_layout.visual_layout_height(fake_window, 960) == 960 + + def test_on_close_request_starts_custom_shutdown_sequence() -> None: calls: list[str] = [] fake_window = SimpleNamespace( @@ -519,12 +584,14 @@ def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path) set_status=lambda message: statuses.append(message), notify_control_state_changed=lambda: calls.append("notify"), ) + fake_window.import_apo_preset_path = MethodType(window.MiniEqWindow.import_apo_preset_path, fake_window) 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_auto_applied is False assert fake_window.output_preset_curve_auto_loaded is False assert statuses == ["Imported APO curve"] assert calls == [ @@ -537,6 +604,51 @@ def test_import_apo_updates_provisional_curve_status_and_control_state(tmp_path) ] +def test_import_apo_preset_path_uses_autoeq_name_and_reveals_utility_pane() -> None: + calls: list[object] = [] + utility_button = FakeUtilityPaneButton(visible=True) + fake_window = SimpleNamespace( + controller=SimpleNamespace( + import_apo_preset=lambda path: calls.append(("import", path)) or 10, + state_signature=lambda: "imported-signature", + build_preset_payload=lambda label: {"name": label}, + ), + selected_band_index=3, + current_preset_name="Studio Reference", + saved_preset_signature="old-signature", + output_preset_auto_applied=True, + output_preset_curve_auto_loaded=True, + utility_pane_button=utility_button, + 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"), + notify_control_state_changed=lambda: calls.append("notify"), + ) + + count = window.MiniEqWindow.import_apo_preset_path( + fake_window, + "/tmp/Example Reference Headphone ParametricEQ.txt", + imported_name="Example Reference Headphone", + ) + + assert count == 10 + 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_auto_applied is False + assert fake_window.output_preset_curve_auto_loaded is False + assert utility_button.active is True + assert calls == [ + ("import", "/tmp/Example Reference Headphone ParametricEQ.txt"), + ("visible-bands", 10), + ("baseline", "Imported APO: Example Reference Headphone"), + "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/tests/test_mini_eq_window_analyzer.py b/tests/test_mini_eq_window_analyzer.py index fb9658b..b7f7146 100644 --- a/tests/test_mini_eq_window_analyzer.py +++ b/tests/test_mini_eq_window_analyzer.py @@ -577,6 +577,13 @@ def test_loudness_summary_falls_back_while_shortterm_is_not_ready() -> None: assert window_analyzer.loudness_summary_lufs(snapshot) == "-21.0 LUFS" +def test_loudness_summary_treats_sub_floor_current_values_as_silence() -> None: + snapshot = analyzer.AnalyzerLoudnessSnapshot(-200.0, -190.0, -19.3) + + assert window_analyzer.loudness_current_lufs(snapshot) is None + assert window_analyzer.loudness_summary_lufs(snapshot) == "--" + + def test_visible_loudness_falls_back_while_shortterm_is_not_ready() -> None: window = AnalyzerSummaryWindow() window.analyzer_loudness_snapshot = analyzer.AnalyzerLoudnessSnapshot(-21.0, float("-inf"), float("-inf")) @@ -590,7 +597,21 @@ def test_visible_loudness_falls_back_while_shortterm_is_not_ready() -> None: assert window.analyzer_loudness_meter_area.accessible_description == "Current -21.0 LUFS · Peak --" +def test_visible_loudness_does_not_fall_back_to_integrated_after_silence() -> None: + window = AnalyzerSummaryWindow() + window.analyzer_loudness_snapshot = analyzer.AnalyzerLoudnessSnapshot(-200.0, -190.0, -19.3) + window.analyzer_session_max_shortterm_lufs = -19.3 + + window.update_analyzer_summary_label() + + assert window.analyzer_summary_label.text == "On · --" + assert window.analyzer_summary_label.tooltip == "Current -- · Peak -19.3 LUFS" + assert window.analyzer_loudness_value_label.text == "--" + assert window.analyzer_loudness_meter_area.accessible_description == "Current -- · Peak -19.3 LUFS" + + def test_loudness_session_max_ignores_silence() -> None: assert window_analyzer.update_loudness_max(None, float("-inf")) is None + assert window_analyzer.update_loudness_max(None, -200.0) is None assert window_analyzer.update_loudness_max(-18.0, -20.0) == pytest.approx(-18.0) assert window_analyzer.update_loudness_max(-18.0, -12.0) == pytest.approx(-12.0) diff --git a/tests/test_mini_eq_window_graph.py b/tests/test_mini_eq_window_graph.py index 15de3d2..f3d6649 100644 --- a/tests/test_mini_eq_window_graph.py +++ b/tests/test_mini_eq_window_graph.py @@ -47,6 +47,45 @@ def set_tooltip_text(self, text: str) -> None: self.tooltip = text +class FakeControl: + def __init__(self) -> None: + self.sensitive = True + self.visible = True + + def set_sensitive(self, sensitive: bool) -> None: + self.sensitive = sensitive + + def set_visible(self, visible: bool) -> None: + self.visible = visible + + +class FakeSpin(FakeControl): + def __init__(self) -> None: + super().__init__() + self.value = 0.0 + + def set_value(self, value: float) -> None: + self.value = value + + +class FakeDropDown(FakeControl): + def __init__(self) -> None: + super().__init__() + self.selected = 0 + + def set_selected(self, selected: int) -> None: + self.selected = selected + + +class FakeToggle(FakeControl): + def __init__(self) -> None: + super().__init__() + self.active = False + + def set_active(self, active: bool) -> None: + self.active = active + + class FakeScale: def __init__(self, value: float) -> None: self.value = value @@ -79,6 +118,47 @@ def __init__( self.inspector_summary_label = FakeLabel() +class SelectedBandEditorWindow(window_graph.MiniEqWindowGraphMixin): + def __init__(self, selected_band_index: int | None) -> None: + self.selected_band_index = selected_band_index + self.controller = SimpleNamespace( + bands=[ + core.EqBand(core.FILTER_TYPES["Bell"], 1000.0, gain_db=2.5), + ] + ) + self.selected_band_label = FakeLabel() + self.selected_band_state_box = FakeControl() + self.selected_band_type_box = FakeControl() + self.selected_band_frequency_box = FakeControl() + self.selected_band_q_box = FakeControl() + self.selected_band_gain_box = FakeControl() + self.selected_band_type_combo = FakeDropDown() + self.selected_band_frequency_spin = FakeSpin() + self.selected_band_q_spin = FakeSpin() + self.selected_band_gain_spin = FakeSpin() + self.selected_band_mute_button = FakeToggle() + self.selected_band_solo_button = FakeToggle() + + def editor_groups(self) -> list[FakeControl]: + return [ + self.selected_band_state_box, + self.selected_band_type_box, + self.selected_band_frequency_box, + self.selected_band_q_box, + self.selected_band_gain_box, + ] + + def editor_controls(self) -> list[FakeControl]: + return [ + self.selected_band_type_combo, + self.selected_band_frequency_spin, + self.selected_band_q_spin, + self.selected_band_gain_spin, + self.selected_band_mute_button, + self.selected_band_solo_button, + ] + + def test_filter_type_label_handles_non_contiguous_filter_values() -> None: assert window_graph.filter_type_label(core.FILTER_TYPES["Allpass"]) == "Allpass" assert window_graph.filter_type_label(core.FILTER_TYPES["Bandpass"]) == "Bandpass" @@ -122,6 +202,41 @@ def test_focus_summary_handles_no_selected_band() -> None: assert window.inspector_summary_label.text == "No Band" +def test_selected_band_editor_keeps_parameter_space_visible_without_selection() -> None: + window = SelectedBandEditorWindow(selected_band_index=None) + window.selected_band_type_combo.selected = 1 + window.selected_band_frequency_spin.value = 640.0 + window.selected_band_q_spin.value = 1.4 + window.selected_band_gain_spin.value = -3.0 + window.selected_band_mute_button.active = True + window.selected_band_solo_button.active = True + + window.update_selected_band_editor() + + assert window.selected_band_label.text == "No Band" + assert window.selected_band_label.tooltip == "No band selected" + assert all(group.visible for group in window.editor_groups()) + assert all(not control.sensitive for control in window.editor_controls()) + assert window.selected_band_type_combo.selected == core.FILTER_TYPE_INDEX_BY_VALUE[core.FILTER_TYPES["Off"]] + assert window.selected_band_frequency_spin.value == window_graph.SELECTED_BAND_PLACEHOLDER_FREQUENCY_HZ + assert window.selected_band_q_spin.value == core.DEFAULT_BAND_Q + assert window.selected_band_gain_spin.value == 0.0 + assert window.selected_band_mute_button.active is False + assert window.selected_band_solo_button.active is False + + +def test_selected_band_editor_enables_parameter_controls_after_selection() -> None: + window = SelectedBandEditorWindow(selected_band_index=0) + + window.update_selected_band_editor() + + assert window.selected_band_label.text == "Band 1" + assert all(group.visible for group in window.editor_groups()) + assert all(control.sensitive for control in window.editor_controls()) + assert window.selected_band_frequency_spin.value == 1000.0 + assert window.selected_band_gain_spin.value == 2.5 + + def test_preamp_change_refreshes_preset_metadata() -> None: calls: list[object] = [] controller = SimpleNamespace(set_preamp_db=lambda value: calls.append(("preamp", value))) @@ -174,3 +289,20 @@ def test_curve_metadata_refresh_updates_preset_state_with_pending_idle(monkeypat assert calls == ["preset-state"] assert test_window.curve_metadata_refresh_source_id == 42 + + +def test_curve_metadata_refresh_idle_notifies_control_clients() -> None: + calls: list[str] = [] + test_window = SimpleNamespace( + curve_metadata_refresh_source_id=42, + ui_shutting_down=False, + update_status_summary=lambda: calls.append("status"), + update_preset_state=lambda: calls.append("preset-state"), + notify_control_state_changed=lambda: calls.append("control-state"), + ) + + keep_source = window_graph.MiniEqWindowGraphMixin.on_curve_metadata_refresh_idle(test_window) + + assert keep_source is False + assert test_window.curve_metadata_refresh_source_id == 0 + assert calls == ["status", "preset-state", "control-state"] diff --git a/tests/test_mini_eq_window_state.py b/tests/test_mini_eq_window_state.py new file mode 100644 index 0000000..90172c8 --- /dev/null +++ b/tests/test_mini_eq_window_state.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from tests._mini_eq_imports import import_mini_eq_module + +window_state = import_mini_eq_module("window_state") + + +class FakeSettings: + def __init__(self) -> None: + self.bindings: list[tuple[str, object, str, object]] = [] + + def bind(self, key: str, window: object, property_name: str, flags: object) -> None: + self.bindings.append((key, window, property_name, flags)) + + +def test_bind_window_state_binds_size_and_maximized(monkeypatch) -> None: + fake_settings = FakeSettings() + fake_window = object() + monkeypatch.setattr(window_state, "create_window_state_settings", lambda: fake_settings) + + assert window_state.bind_window_state(fake_window) is fake_settings + assert [(key, property_name) for key, _window, property_name, _flags in fake_settings.bindings] == [ + ("window-width", "default-width"), + ("window-height", "default-height"), + ("window-maximized", "maximized"), + ] + assert all(window is fake_window for _key, window, _property_name, _flags in fake_settings.bindings) + + +def test_bind_window_state_noops_when_schema_is_unavailable(monkeypatch) -> None: + monkeypatch.setattr(window_state, "create_window_state_settings", lambda: None) + + assert window_state.bind_window_state(object()) is None diff --git a/tests/test_release_preflight.py b/tests/test_release_preflight.py index 56ee577..c6e105a 100644 --- a/tests/test_release_preflight.py +++ b/tests/test_release_preflight.py @@ -23,6 +23,9 @@ def test_leak_pattern_matches_common_credential_prefixes() -> None: "value=" + "AK" + "IA" + ("A" * 16), "value=" + "AS" + "IA" + ("A" * 16), "api" + "_key=value", + "to" + "ken=" + ("A" * 40), + "sec" + "ret=" + ("A" * 40), + "sec" + "ret_key=" + ("A" * 40), "/" + "home/user/project", ] ) @@ -50,6 +53,12 @@ def test_allowed_matches_still_cover_public_release_references() -> None: assert release_preflight.allowed_leak_match(line) +def test_leak_pattern_ignores_regular_token_identifiers() -> None: + assert not leak_match("tokens = normalize_search_query(query)") + assert not leak_match("first_token = tokens[0]") + assert not leak_match("def score(tokens: list[str]) -> int:") + + def test_pipewire_gobject_build_environment_error_lists_missing_tools(monkeypatch) -> None: monkeypatch.setattr(release_preflight, "PIPEWIRE_GOBJECT_BUILD_TOOLS", ("definitely-missing-pwg-tool",)) monkeypatch.setattr(release_preflight, "PIPEWIRE_GOBJECT_PKG_CONFIG_MODULES", ()) @@ -97,6 +106,26 @@ def test_release_preflight_runs_headless_pipewire_runtime_smoke(monkeypatch) -> ] +def test_release_preflight_autoeq_live_notice_tracks_autoeq_paths(monkeypatch, capsys) -> None: + observed_paths: list[Path] = [] + + monkeypatch.setattr(release_preflight, "extension_comparison_base_tag", lambda: "v0.7.4") + + def changed_paths(_base_tag: str, paths: tuple[Path, ...]) -> list[str]: + observed_paths.extend(paths) + return ["src/mini_eq/autoeq.py"] + + monkeypatch.setattr(release_preflight, "changed_paths_for_review", changed_paths) + + release_preflight.run_autoeq_live_check_notice() + + output = capsys.readouterr().out + assert Path("src/mini_eq/autoeq.py") in observed_paths + assert Path("tools/check_autoeq_live.py") in observed_paths + assert "AutoEQ.app live compatibility check may be needed" in output + assert "python3 tools/check_autoeq_live.py" in output + + def test_release_preflight_uses_hosted_headless_pipewire_defaults(monkeypatch) -> None: commands: list[list[str | Path]] = [] diff --git a/tools/check_autoeq_live.py b/tools/check_autoeq_live.py new file mode 100644 index 0000000..34cc939 --- /dev/null +++ b/tools/check_autoeq_live.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from mini_eq import autoeq # noqa: E402 +from mini_eq.core import EqBand, parse_apo_file # noqa: E402 + + +@dataclass(frozen=True) +class AutoEqLiveCheckResult: + entry: autoeq.AutoEqEntry + entry_count: int + target_count: int + preamp_db: float + band_count: int + + +def profile_description(name: str, source: str | None, form: str | None, rig: str | None) -> str: + parts = [part for part in (name, source, form) if part] + if rig: + parts.append(rig) + return " / ".join(parts) + + +def validate_entries(entries: list[autoeq.AutoEqEntry]) -> None: + if not entries: + raise RuntimeError("AutoEq profile list is empty after parsing") + + for entry in entries: + if not entry.name.strip() or not entry.source.strip() or not entry.form.strip(): + raise RuntimeError("AutoEq profile list includes an entry without name, source, or form") + + +def validate_targets(targets: list[object]) -> int: + if not targets: + raise RuntimeError("AutoEq target list is empty") + + labeled_targets = 0 + for target in targets: + if not isinstance(target, dict): + continue + label = str(target.get("label") or "").strip() + if label: + labeled_targets += 1 + + if labeled_targets == 0: + raise RuntimeError("AutoEq target list does not include any labeled targets") + return labeled_targets + + +def entry_matches_probe( + entry: autoeq.AutoEqEntry, + *, + name: str, + source: str | None, + form: str | None, + rig: str | None, +) -> bool: + if entry.name.casefold() != name.casefold(): + return False + if source and entry.source.casefold() != source.casefold(): + return False + if form and entry.form.casefold() != form.casefold(): + return False + return not rig or entry.rig.casefold() == rig.casefold() + + +def select_probe_entry( + entries: list[autoeq.AutoEqEntry], + *, + name: str | None, + source: str | None, + form: str | None, + rig: str | None, +) -> autoeq.AutoEqEntry: + if not entries: + raise RuntimeError("AutoEq profile list is empty after parsing") + + if name is None: + return entries[0] + + for entry in entries: + if entry_matches_probe(entry, name=name, source=source, form=form, rig=rig): + return entry + + candidates = autoeq.search_autoeq_entries(entries, name, limit=8) + if source: + candidates = [entry for entry in candidates if entry.source.casefold() == source.casefold()] + if form: + candidates = [entry for entry in candidates if entry.form.casefold() == form.casefold()] + if rig: + candidates = [entry for entry in candidates if entry.rig.casefold() == rig.casefold()] + + if candidates: + return candidates[0] + + sample = ", ".join( + profile_description(entry.name, entry.source, entry.form, entry.rig or None) + for entry in autoeq.search_autoeq_entries(entries, name, limit=5) + ) + if not sample: + sample = "no nearby profile matches" + + raise RuntimeError( + f"AutoEq profile probe could not find {profile_description(name, source, form, rig)}; nearest matches: {sample}" + ) + + +def parse_generated_apo(text: str) -> tuple[float, list[EqBand]]: + if "Preamp:" not in text or "Filter " not in text: + raise RuntimeError("AutoEq generated text does not look like an Equalizer APO preset") + + with tempfile.TemporaryDirectory(prefix="mini-eq-autoeq-live-") as temp_dir: + path = Path(temp_dir) / "autoeq-live.txt" + path.write_text(text, encoding="utf-8") + preamp_db, bands = parse_apo_file(str(path)) + + if not bands: + raise RuntimeError("Mini EQ APO parser did not import any AutoEq filters") + return preamp_db, bands + + +def check_autoeq_live( + *, + profile_name: str | None = None, + profile_source: str | None = None, + profile_form: str | None = None, + profile_rig: str | None = None, + timeout: int = autoeq.AUTOEQ_REQUEST_TIMEOUT_SECONDS, +) -> AutoEqLiveCheckResult: + entries_text = autoeq.fetch_text(autoeq.AUTOEQ_APP_ENTRIES_URL, timeout=timeout) + entries = autoeq.parse_autoeq_app_entries(entries_text) + validate_entries(entries) + + targets_text = autoeq.fetch_text(autoeq.AUTOEQ_APP_TARGETS_URL, timeout=timeout) + targets = autoeq.parse_autoeq_targets_data(targets_text) + target_count = validate_targets(targets) + + entry = select_probe_entry( + entries, + name=profile_name, + source=profile_source, + form=profile_form, + rig=profile_rig, + ) + data = autoeq.post_json( + autoeq.AUTOEQ_APP_EQUALIZE_URL, + autoeq.autoeq_equalize_body(entry, targets), + timeout=timeout, + ) + apo_text = autoeq.format_autoeq_parametric_eq(data.get("parametric_eq")) + preamp_db, bands = parse_generated_apo(apo_text) + + return AutoEqLiveCheckResult( + entry=entry, + entry_count=len(entries), + target_count=target_count, + preamp_db=preamp_db, + band_count=len(bands), + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a live AutoEQ.app compatibility smoke test.") + parser.add_argument( + "--profile", + default=None, + help="specific AutoEQ profile name to equalize; defaults to the first parsed profile", + ) + parser.add_argument("--source", default=None, help="expected AutoEQ measurement source when --profile is set") + parser.add_argument("--form", default=None, help="expected AutoEQ profile form when --profile is set") + parser.add_argument("--rig", default=None, help="optional expected AutoEQ measurement rig") + parser.add_argument( + "--timeout", + type=int, + default=autoeq.AUTOEQ_REQUEST_TIMEOUT_SECONDS, + help="network timeout in seconds for each AutoEQ request", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.timeout <= 0: + parser.error("--timeout must be greater than zero") + + try: + result = check_autoeq_live( + profile_name=args.profile, + profile_source=args.source, + profile_form=args.form, + profile_rig=args.rig, + timeout=args.timeout, + ) + except Exception as exc: + print(f"AutoEQ live check failed: {exc}", file=sys.stderr) + return 1 + + print("AutoEQ live check passed.") + print(f"Profiles parsed: {result.entry_count}") + print(f"Targets parsed: {result.target_count}") + print( + "Probe profile: " + f"{profile_description(result.entry.name, result.entry.source, result.entry.form, result.entry.rig or None)}" + ) + print(f"Imported APO filters: {result.band_count}") + print(f"Imported preamp: {result.preamp_db:.2f} dB") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/gnome-shell-extension/fake_mini_eq_control.py b/tools/gnome-shell-extension/fake_mini_eq_control.py index f2f19e6..5c69a51 100755 --- a/tools/gnome-shell-extension/fake_mini_eq_control.py +++ b/tools/gnome-shell-extension/fake_mini_eq_control.py @@ -119,8 +119,13 @@ def state(self) -> dict[str, GLib.Variant]: "eq_enabled": GLib.Variant("b", self.eq_enabled), "routed": GLib.Variant("b", self.routed), "preset_name": GLib.Variant("s", self.preset_name), + "curve_name": GLib.Variant("s", self.preset_name), + "curve_status": GLib.Variant("s", "preset"), + "curve_label": GLib.Variant("s", self.preset_name), "output_sink": GLib.Variant("s", "Demo Output"), "output_preset_name": GLib.Variant("s", self.output_preset_name), + "output_preset_status": GLib.Variant("s", "applied"), + "output_preset_label": GLib.Variant("s", f"Applied - {self.output_preset_name}"), "output_preset_auto_applied": GLib.Variant("b", True), "analyzer_enabled": GLib.Variant("b", self.analyzer_enabled), "background_mode": GLib.Variant("b", True), diff --git a/tools/prepare_release.py b/tools/prepare_release.py index 2258bf2..00e0c59 100755 --- a/tools/prepare_release.py +++ b/tools/prepare_release.py @@ -154,7 +154,10 @@ def main() -> int: for edit in changed: edit.path.write_text(edit.after, encoding="utf-8") print(f"updated {edit.path}") - print("Run tests/test_version_metadata.py and review the generated release notes before committing.") + print( + "Run tests/test_version_metadata.py and review the generated release notes before committing. " + "They are shown in AppStream and the About dialog." + ) return 0 diff --git a/tools/release_preflight.py b/tools/release_preflight.py index 630d932..d992592 100755 --- a/tools/release_preflight.py +++ b/tools/release_preflight.py @@ -19,7 +19,8 @@ ROOT = Path(__file__).resolve().parents[1] LEAK_PATTERN = ( - r"(/home/|/Users/|secret|token|api[_-]?key|github_pat|" + r"(/home/|/Users/|(^|[^A-Za-z0-9_])secret[ \t]*[:=]|(^|[^A-Za-z0-9_])secret[_-]?key|" + r"(^|[^A-Za-z0-9_])token[ \t]*=|(^|[^A-Za-z0-9_])token[_-]?value[ \t]*=|api[_-]?key|github_pat|" r"gh[pousr]_[A-Za-z0-9_]{20,}|pypi-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|" r"AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|BEGIN [A-Z0-9 ]*PRIVATE KEY)" ) @@ -51,6 +52,13 @@ Path("src/mini_eq/window_preferences.py"), Path("extensions/gnome-shell/mini-eq@bhack.github.io/extension.js"), ) +AUTOEQ_LIVE_REVIEW_PATHS = ( + Path(".github/workflows/autoeq-live.yml"), + Path("src/mini_eq/autoeq.py"), + Path("src/mini_eq/window_autoeq.py"), + Path("tests/test_mini_eq_autoeq.py"), + Path("tools/check_autoeq_live.py"), +) PIPEWIRE_GOBJECT_BUILD_TOOLS = ("g-ir-compiler", "g-ir-scanner", "pkg-config") PIPEWIRE_GOBJECT_PKG_CONFIG_MODULES = ("glib-2.0", "gio-2.0", "gobject-2.0", "libpipewire-0.3") PIPEWIRE_GOBJECT_DEBIAN_BUILD_PACKAGES = ( @@ -248,6 +256,26 @@ def run_background_portal_smoke_notice() -> None: print("Run one clean-permission Flatpak portal smoke in a real GNOME session before releasing this change.") +def run_autoeq_live_check_notice() -> None: + base_tag = extension_comparison_base_tag() + if base_tag is None: + print("\nAutoEQ.app live compatibility notice skipped; no release tag found.") + return + + changes = changed_paths_for_review(base_tag, AUTOEQ_LIVE_REVIEW_PATHS) + if not changes: + print(f"\nAutoEQ.app live compatibility check not indicated; AutoEQ integration unchanged since {base_tag}.") + return + + print(f"\nAutoEQ.app live compatibility check may be needed; AutoEQ integration changed since {base_tag}:") + for path in changes: + print(f" {path}") + print( + "Run python3 tools/check_autoeq_live.py before release. A failure is a live-service or format-drift " + "signal to investigate, not an automatic blocker for unrelated fixes." + ) + + def run_flatpak_runtime_smoke_notice() -> None: base_tag = extension_comparison_base_tag() if base_tag is None: @@ -483,6 +511,7 @@ def main() -> int: run_flatpak_runtime_smoke_notice() run_live_ui_runtime_smoke_notice() run_background_portal_smoke_notice() + run_autoeq_live_check_notice() run([python, "-m", "ruff", "check", "."]) run([python, "-m", "ruff", "format", "--check", "."]) run([python, "-m", "pytest", "-q"]) diff --git a/tools/render_demo_screenshot.py b/tools/render_demo_screenshot.py index 2ae9d3a..4401d34 100644 --- a/tools/render_demo_screenshot.py +++ b/tools/render_demo_screenshot.py @@ -114,7 +114,7 @@ def do_activate(self) -> None: self.window.refresh_preset_list() self.window.set_visible(True) self.window.present() - self.window.schedule_post_present_setup() + self.window.schedule_startup_ready() GLib.timeout_add(self.delay_ms, self.on_capture_timeout) def on_capture_timeout(self) -> bool: