Skip to content

Commit 76e34ca

Browse files
authored
Merge pull request #36 from bhack/issue-35-auto-preset-startup-scope
Fix startup auto preset route recovery
2 parents 22b3797 + 9652329 commit 76e34ca

16 files changed

Lines changed: 1286 additions & 115 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 0.8.6 - 2026-06-04
4+
5+
- Fix auto presets at login for outputs where PipeWire restores the selected
6+
speaker sink before reporting the active port route.
7+
38
## 0.8.5 - 2026-05-31
49

510
- Fixed auto presets sometimes resetting to Neutral when PipeWire route

data/io.github.bhack.mini-eq.metainfo.xml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,23 @@
3333
</description>
3434
<screenshots>
3535
<screenshot type="default">
36-
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.5/docs/screenshots/mini-eq.png</image>
36+
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.6/docs/screenshots/mini-eq.png</image>
3737
<caption>Adjust sound output with equalizer controls</caption>
3838
</screenshot>
3939
<screenshot>
40-
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.5/docs/screenshots/mini-eq-dark.png</image>
40+
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.6/docs/screenshots/mini-eq-dark.png</image>
4141
<caption>Use the equalizer with dark style</caption>
4242
</screenshot>
4343
</screenshots>
4444
<url type="homepage">https://github.com/bhack/mini-eq</url>
4545
<url type="bugtracker">https://github.com/bhack/mini-eq/issues</url>
4646
<url type="vcs-browser">https://github.com/bhack/mini-eq</url>
4747
<releases>
48+
<release version="0.8.6" date="2026-06-04">
49+
<description>
50+
<p>Fix auto presets at login for outputs where PipeWire restores the selected speaker sink before reporting the active port route.</p>
51+
</description>
52+
</release>
4853
<release version="0.8.5" date="2026-05-31">
4954
<description>
5055
<p>Fixed auto presets sometimes resetting to Neutral when PipeWire route metadata changed.</p>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "mini-eq"
7-
version = "0.8.5"
7+
version = "0.8.6"
88
description = "Compact PipeWire system-wide parametric equalizer for Linux desktops."
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/mini_eq/core.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dataclasses import dataclass
1010
from functools import lru_cache
1111
from pathlib import Path
12+
from urllib.parse import unquote
1213

1314
import numpy as np
1415

@@ -23,6 +24,7 @@
2324
PRESET_FILE_SUFFIX = ".json"
2425
OUTPUT_PRESET_LINKS_VERSION = 1
2526
OUTPUT_PRESET_LINKS_FILE = "output-presets.json"
27+
OUTPUT_PRESET_ROUTE_KEY_PREFIX = "pipewire-route:v1:"
2628
EQ_MODE_APO = 6
2729
SAMPLE_RATE = 48000.0
2830
GRAPH_FREQ_MIN = 20.0
@@ -426,6 +428,35 @@ def normalize_output_preset_key_candidates(output_keys: str | Iterable[str | Non
426428
return normalized
427429

428430

431+
def parse_output_preset_route_key(output_key: str | None) -> dict[str, object] | None:
432+
key = str(output_key or "").strip()
433+
if not key.startswith(OUTPUT_PRESET_ROUTE_KEY_PREFIX):
434+
return None
435+
436+
fields: dict[str, str] = {}
437+
for part in key[len(OUTPUT_PRESET_ROUTE_KEY_PREFIX) :].split(";"):
438+
if "=" not in part:
439+
return None
440+
name, value = part.split("=", 1)
441+
fields[name] = unquote(value)
442+
443+
device_name = fields.get("device", "").strip()
444+
route_name = fields.get("route", "").strip()
445+
try:
446+
route_device = int(fields.get("route-device", ""))
447+
except ValueError:
448+
return None
449+
450+
if not device_name or not route_name or route_device < 0:
451+
return None
452+
453+
return {
454+
"device": device_name,
455+
"route": route_name,
456+
"route_device": route_device,
457+
}
458+
459+
429460
def load_output_preset_config() -> tuple[dict[str, str], str | None]:
430461
links_path = output_preset_links_path()
431462
if not links_path.exists():
@@ -491,6 +522,34 @@ def get_output_preset_link(output_keys: str | Iterable[str | None] | None) -> st
491522
return match[1] if match is not None else None
492523

493524

525+
def get_output_preset_route_device_link_match(
526+
device_name: str | None,
527+
route_device: int | None,
528+
) -> tuple[str, str] | None:
529+
device = str(device_name or "").strip()
530+
try:
531+
route_device_id = int(route_device)
532+
except (TypeError, ValueError):
533+
return None
534+
535+
if not device or route_device_id < 0:
536+
return None
537+
538+
links, _default_preset = load_output_preset_config()
539+
matches: list[tuple[str, str]] = []
540+
for output_key, preset_name in links.items():
541+
route_key = parse_output_preset_route_key(output_key)
542+
if route_key is None:
543+
continue
544+
if route_key["device"] == device and route_key["route_device"] == route_device_id:
545+
matches.append((output_key, preset_name))
546+
547+
if len(matches) == 1:
548+
return matches[0]
549+
550+
return None
551+
552+
494553
def get_default_preset_name() -> str | None:
495554
_links, default_preset = load_output_preset_config()
496555
return default_preset

src/mini_eq/diagnostics.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
from datetime import UTC, datetime
6+
from pathlib import Path
7+
8+
from gi.repository import GLib
9+
10+
STARTUP_TRACE_ENV = "MINI_EQ_STARTUP_TRACE"
11+
STARTUP_TRACE_DIR_NAME = "mini-eq"
12+
STARTUP_TRACE_FILE_NAME = "startup-trace.log"
13+
14+
15+
def startup_trace_enabled() -> bool:
16+
value = os.environ.get(STARTUP_TRACE_ENV, "")
17+
return value.strip().lower() not in {"", "0", "false", "no", "off"}
18+
19+
20+
def startup_trace_path() -> Path:
21+
return Path(GLib.get_user_state_dir()) / STARTUP_TRACE_DIR_NAME / STARTUP_TRACE_FILE_NAME
22+
23+
24+
def describe_output_preset_target(target: object | None) -> dict[str, object] | None:
25+
if target is None:
26+
return None
27+
28+
route = getattr(target, "route", None)
29+
route_info = None
30+
if route is not None:
31+
route_info = {
32+
"description": _json_safe(getattr(route, "description", None)),
33+
"device_name": _json_safe(getattr(route, "device_name", None)),
34+
"name": _json_safe(getattr(route, "name", None)),
35+
"output_preset_key": _json_safe(getattr(route, "output_preset_key", None)),
36+
"route_device": _json_safe(getattr(route, "route_device", None)),
37+
}
38+
39+
return {
40+
"device_name": _json_safe(getattr(target, "device_name", None)),
41+
"has_route_key": bool(getattr(target, "has_route_key", False)),
42+
"keys": _json_safe(tuple(getattr(target, "keys", ()) or ())),
43+
"link_key": _json_safe(getattr(target, "link_key", "")),
44+
"output_key": _json_safe(getattr(target, "output_key", None)),
45+
"route": route_info,
46+
"route_device": _json_safe(getattr(target, "route_device", None)),
47+
}
48+
49+
50+
def describe_output_preset_snapshot(snapshot: object | None) -> dict[str, object] | None:
51+
if snapshot is None:
52+
return None
53+
54+
return {
55+
"identity": _json_safe(getattr(snapshot, "identity", None)),
56+
"sink_name": _json_safe(getattr(snapshot, "sink_name", None)),
57+
"target": describe_output_preset_target(getattr(snapshot, "target", None)),
58+
}
59+
60+
61+
def describe_output_preset_transition(transition: object | None) -> dict[str, object] | None:
62+
if transition is None:
63+
return None
64+
65+
return {
66+
"changed": bool(getattr(transition, "changed", False)),
67+
"current": describe_output_preset_snapshot(getattr(transition, "current", None)),
68+
"previous": describe_output_preset_snapshot(getattr(transition, "previous", None)),
69+
}
70+
71+
72+
def trace_startup_event(event: str, **fields: object) -> None:
73+
if not startup_trace_enabled():
74+
return
75+
76+
path = startup_trace_path()
77+
record = {
78+
"event": event,
79+
"timestamp": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
80+
}
81+
record.update({key: _json_safe(value) for key, value in fields.items()})
82+
83+
try:
84+
path.parent.mkdir(parents=True, exist_ok=True)
85+
with path.open("a", encoding="utf-8") as trace_file:
86+
trace_file.write(json.dumps(record, sort_keys=True, separators=(",", ":")))
87+
trace_file.write("\n")
88+
except Exception:
89+
return
90+
91+
92+
def _json_safe(value: object) -> object:
93+
if value is None or isinstance(value, str | int | float | bool):
94+
return value
95+
96+
if isinstance(value, tuple | list | set):
97+
return [_json_safe(item) for item in value]
98+
99+
if isinstance(value, dict):
100+
return {str(key): _json_safe(item) for key, item in value.items()}
101+
102+
return str(value)

0 commit comments

Comments
 (0)