Skip to content

Commit c3b5705

Browse files
committed
Fix output presets on PipeWire port changes
1 parent 69ed1ca commit c3b5705

7 files changed

Lines changed: 307 additions & 6 deletions

File tree

src/mini_eq/deps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"Device.enum_all_params",
2020
"Device.enum_params",
2121
"Device.new",
22+
"Device.subscribe_params",
2223
"Param.new_props_controls",
2324
"RouteInfo.new_from_param",
2425
"Stream.set_pipewire_property",

src/mini_eq/pipewire_routes.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,13 @@ def output_route_for_sink(self, sink) -> PipeWireOutputRoute | None:
9494
return None
9595

9696
if sink.card_profile_device > 0:
97-
for route in output_routes:
98-
if route.route_device == sink.card_profile_device:
99-
return route
97+
matching_device_routes = [
98+
route for route in output_routes if route.route_device == sink.card_profile_device
99+
]
100+
if len(matching_device_routes) == 1:
101+
return matching_device_routes[0]
102+
if len(matching_device_routes) > 1:
103+
return None
100104

101105
if len(output_routes) == 1:
102106
return output_routes[0]

src/mini_eq/window.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,57 @@ def compact_warning_title(message: str) -> str:
7171
return COMPACT_WARNING_TITLES.get(message, message)
7272

7373

74+
def output_preset_target_identity(owner: object, fallback: str | None) -> str | None:
75+
target = None
76+
target_factory = getattr(owner, "output_preset_target", None)
77+
if callable(target_factory):
78+
try:
79+
target = target_factory()
80+
except Exception:
81+
target = None
82+
83+
if target is not None:
84+
try:
85+
link_key = str(getattr(target, "link_key", "") or "").strip()
86+
except Exception:
87+
link_key = ""
88+
if link_key:
89+
return link_key
90+
91+
try:
92+
keys = tuple(getattr(target, "keys", ()) or ())
93+
except Exception:
94+
keys = ()
95+
for key in keys:
96+
key_text = str(key or "").strip()
97+
if key_text:
98+
return key_text
99+
100+
controller = getattr(owner, "controller", None)
101+
if controller is not None:
102+
link_key_factory = getattr(controller, "output_preset_link_key", None)
103+
if callable(link_key_factory):
104+
try:
105+
link_key = str(link_key_factory() or "").strip()
106+
except Exception:
107+
link_key = ""
108+
if link_key:
109+
return link_key
110+
111+
keys_factory = getattr(controller, "output_preset_keys", None)
112+
if callable(keys_factory):
113+
try:
114+
keys = tuple(keys_factory() or ())
115+
except Exception:
116+
keys = ()
117+
for key in keys:
118+
key_text = str(key or "").strip()
119+
if key_text:
120+
return key_text
121+
122+
return fallback
123+
124+
74125
class MiniEqWindow(
75126
MiniEqWindowPresetMixin,
76127
MiniEqWindowAnalyzerMixin,
@@ -128,6 +179,7 @@ def __init__(
128179
self.output_preset_curve_auto_loaded = False
129180
self.updating_output_preset_switch = False
130181
self.last_output_preset_sink_name: str | None = None
182+
self.last_output_preset_target_identity: str | None = None
131183
self.preset_monitor: Gio.FileMonitor | None = None
132184
self.preset_refresh_source_id = 0
133185
self.analyzer_enabled = load_monitor_enabled()
@@ -763,6 +815,8 @@ def refresh_output_sinks(self, *, handle_observed_output_change: bool = True) ->
763815

764816
active = self.controller.output_sink
765817
previous_output = self.last_output_preset_sink_name
818+
previous_output_identity = getattr(self, "last_output_preset_target_identity", previous_output)
819+
active_output_identity = output_preset_target_identity(self, active)
766820
previous_output_preset_auto_loaded = self.output_preset_curve_auto_loaded
767821
visible_sinks = self.list_visible_output_sinks()
768822
visible_sink_names = [sink.node_name for sink in visible_sinks if sink.node_name is not None]
@@ -789,11 +843,12 @@ def refresh_output_sinks(self, *, handle_observed_output_change: bool = True) ->
789843
finally:
790844
self.updating_output_combo = False
791845

792-
output_changed = previous_output is not None and previous_output != active
846+
output_changed = previous_output_identity is not None and previous_output_identity != active_output_identity
793847
# App-originated selector refreshes should not consume the output
794848
# transition; the next PipeWire-observed refresh owns preset handling.
795849
if handle_observed_output_change or previous_output is None:
796850
self.last_output_preset_sink_name = active
851+
self.last_output_preset_target_identity = active_output_identity
797852
self.update_preset_state()
798853
self.update_info_label()
799854
self.update_status_summary()

tests/test_mini_eq_deps.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ def test_pipewire_gobject_check_requires_current_library_version(monkeypatch) ->
9595
fake_pwg = SimpleNamespace(
9696
get_library_version=lambda: "0.3.4",
9797
Core=SimpleNamespace(set_pipewire_property=object()),
98-
Device=SimpleNamespace(enum_all_params=object(), enum_params=object(), new=object()),
98+
Device=SimpleNamespace(
99+
enum_all_params=object(),
100+
enum_params=object(),
101+
new=object(),
102+
subscribe_params=object(),
103+
),
99104
Param=SimpleNamespace(new_props_controls=object()),
100105
RouteInfo=SimpleNamespace(new_from_param=object()),
101106
Stream=SimpleNamespace(set_pipewire_property=object()),
@@ -123,7 +128,12 @@ def test_pipewire_gobject_check_requires_property_override_symbols(monkeypatch)
123128
fake_pwg = SimpleNamespace(
124129
get_library_version=lambda: "0.3.5",
125130
Core=SimpleNamespace(),
126-
Device=SimpleNamespace(enum_all_params=object(), enum_params=object(), new=object()),
131+
Device=SimpleNamespace(
132+
enum_all_params=object(),
133+
enum_params=object(),
134+
new=object(),
135+
subscribe_params=object(),
136+
),
127137
Param=SimpleNamespace(new_props_controls=object()),
128138
RouteInfo=SimpleNamespace(new_from_param=object()),
129139
Stream=SimpleNamespace(set_pipewire_property=object()),

tests/test_mini_eq_output_presets.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,39 @@ def test_output_preset_actions_use_route_key_when_available(monkeypatch, tmp_pat
806806
assert test_window.statuses[-1] == "Auto preset cleared"
807807

808808

809+
def test_output_scope_state_follows_same_sink_route_change(monkeypatch, tmp_path) -> None:
810+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
811+
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
812+
controller = make_controller("alsa_output.internal")
813+
route = SimpleNamespace(
814+
description="Headphones",
815+
name="analog-output-headphones",
816+
output_preset_key="pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=6",
817+
)
818+
819+
def output_preset_target():
820+
return SimpleNamespace(
821+
output_key=controller.output_sink,
822+
route=route,
823+
keys=(route.output_preset_key, controller.output_sink),
824+
link_key=route.output_preset_key,
825+
has_route_key=True,
826+
)
827+
828+
controller.output_preset_target = output_preset_target
829+
test_window = OutputPresetWindow(controller)
830+
831+
test_window.update_output_preset_state()
832+
assert test_window.output_scope_state_label.text == "Headphones"
833+
834+
route.description = "Speakers"
835+
route.name = "analog-output-speaker"
836+
route.output_preset_key = "pipewire-route:v1:device=alsa_card.test;route=analog-output-speaker;route-device=6"
837+
838+
test_window.update_output_preset_state()
839+
assert test_window.output_scope_state_label.text == "Speakers"
840+
841+
809842
def test_deleting_only_loaded_preset_keeps_curve_and_allows_neutral_reset(monkeypatch, tmp_path) -> None:
810843
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "presets")
811844
monkeypatch.setattr(core, "OUTPUT_PRESET_LINKS_PATH", tmp_path / "output-presets.json")
@@ -1134,6 +1167,54 @@ def test_pipewire_observed_output_change_runs_output_preset_handling() -> None:
11341167
assert fake_window.last_output_preset_sink_name == "alsa_output.usb"
11351168

11361169

1170+
def test_pipewire_observed_port_scope_change_runs_output_preset_handling() -> None:
1171+
calls: list[object] = []
1172+
old_route_key = "pipewire-route:v1:device=alsa_card.test;route=analog-output-headphones;route-device=6"
1173+
new_route_key = "pipewire-route:v1:device=alsa_card.test;route=analog-output-speaker;route-device=6"
1174+
target = SimpleNamespace(
1175+
link_key=new_route_key,
1176+
keys=(new_route_key, "alsa_output.internal"),
1177+
)
1178+
fake_window = SimpleNamespace(
1179+
ui_shutting_down=False,
1180+
controller=SimpleNamespace(
1181+
output_sink="alsa_output.internal",
1182+
follow_default_output=True,
1183+
get_default_output_sink_name=lambda: "alsa_output.internal",
1184+
get_sink=lambda _sink_name: None,
1185+
),
1186+
last_output_preset_sink_name="alsa_output.internal",
1187+
last_output_preset_target_identity=old_route_key,
1188+
output_preset_auto_applied=True,
1189+
output_preset_curve_auto_loaded=True,
1190+
post_present_ready=True,
1191+
list_visible_output_sinks=lambda: [],
1192+
build_output_sink_labels=lambda _sinks: [],
1193+
follow_default_output_label=lambda: "Follow system output",
1194+
output_sink_names=[],
1195+
output_sink_labels=[],
1196+
output_sink_model=FakeModel(),
1197+
output_combo=FakeCombo(),
1198+
updating_output_combo=False,
1199+
output_preset_target=lambda: target,
1200+
update_preset_state=lambda: calls.append("preset-state"),
1201+
update_info_label=lambda: calls.append("info"),
1202+
update_status_summary=lambda: calls.append("summary"),
1203+
apply_output_preset_for_current_output=lambda **kwargs: calls.append(("auto", kwargs)),
1204+
)
1205+
1206+
window.MiniEqWindow.refresh_output_sinks(fake_window)
1207+
1208+
assert calls == [
1209+
"preset-state",
1210+
"info",
1211+
"summary",
1212+
("auto", {"reset_auto_preset_without_link": True, "announce_no_output_preset": True}),
1213+
]
1214+
assert fake_window.last_output_preset_sink_name == "alsa_output.internal"
1215+
assert fake_window.last_output_preset_target_identity == new_route_key
1216+
1217+
11371218
def test_manual_output_refresh_updates_selector_without_handling_observed_output_change() -> None:
11381219
calls: list[object] = []
11391220
fake_window = SimpleNamespace(

tests/test_mini_eq_pipewire_backend.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,93 @@ def test_output_preset_keys_prefer_matching_active_route(monkeypatch: pytest.Mon
470470
)
471471

472472

473+
def test_output_preset_keys_use_single_route_even_when_profile_device_is_stale(
474+
monkeypatch: pytest.MonkeyPatch,
475+
) -> None:
476+
backend = pw_backend.PipeWireBackend()
477+
backend._Pwg = SimpleNamespace(Device=FakeDeviceApi, RouteInfo=FakeRouteInfoApi)
478+
sink = pw_backend.PipeWireNode(
479+
bound_id=39,
480+
object_serial="67",
481+
media_class=pw_backend.AUDIO_SINK,
482+
node_name="alsa_output.test",
483+
node_description="Test Sink",
484+
application_name=None,
485+
node_dont_move=False,
486+
device_id=72,
487+
card_profile_device=8,
488+
)
489+
speakers = pw_routes.PipeWireOutputRoute(
490+
device_bound_id=72,
491+
device_name="alsa_card.test",
492+
index=0,
493+
route_device=6,
494+
profile=0,
495+
priority=100,
496+
direction="Output",
497+
name="analog-output-speaker",
498+
description="Speakers",
499+
availability="unknown",
500+
)
501+
502+
monkeypatch.setattr(backend, "audio_sink_by_name", lambda _name: sink)
503+
monkeypatch.setattr(backend, "_device_proxy_by_bound_id", lambda _bound_id: object())
504+
monkeypatch.setattr(backend, "_enumerate_device_routes", lambda _device, _bound_id: [speakers])
505+
506+
assert backend.output_preset_keys_for_sink_name("alsa_output.test") == (
507+
"pipewire-route:v1:device=alsa_card.test;route=analog-output-speaker;route-device=6",
508+
"alsa_output.test",
509+
)
510+
511+
512+
def test_output_preset_keys_do_not_guess_between_routes_sharing_profile_device(
513+
monkeypatch: pytest.MonkeyPatch,
514+
) -> None:
515+
backend = pw_backend.PipeWireBackend()
516+
backend._Pwg = SimpleNamespace(Device=FakeDeviceApi, RouteInfo=FakeRouteInfoApi)
517+
sink = pw_backend.PipeWireNode(
518+
bound_id=39,
519+
object_serial="67",
520+
media_class=pw_backend.AUDIO_SINK,
521+
node_name="alsa_output.test",
522+
node_description="Test Sink",
523+
application_name=None,
524+
node_dont_move=False,
525+
device_id=72,
526+
card_profile_device=6,
527+
)
528+
speakers = pw_routes.PipeWireOutputRoute(
529+
device_bound_id=72,
530+
device_name="alsa_card.test",
531+
index=0,
532+
route_device=6,
533+
profile=0,
534+
priority=100,
535+
direction="Output",
536+
name="analog-output-speaker",
537+
description="Speakers",
538+
availability="unknown",
539+
)
540+
headphones = pw_routes.PipeWireOutputRoute(
541+
device_bound_id=72,
542+
device_name="alsa_card.test",
543+
index=1,
544+
route_device=6,
545+
profile=0,
546+
priority=200,
547+
direction="Output",
548+
name="analog-output-headphones",
549+
description="Headphones",
550+
availability="yes",
551+
)
552+
553+
monkeypatch.setattr(backend, "audio_sink_by_name", lambda _name: sink)
554+
monkeypatch.setattr(backend, "_device_proxy_by_bound_id", lambda _bound_id: object())
555+
monkeypatch.setattr(backend, "_enumerate_device_routes", lambda _device, _bound_id: [speakers, headphones])
556+
557+
assert backend.output_preset_keys_for_sink_name("alsa_output.test") == ("alsa_output.test",)
558+
559+
473560
def test_output_preset_keys_fall_back_to_sink_name_without_route_api(monkeypatch: pytest.MonkeyPatch) -> None:
474561
backend = pw_backend.PipeWireBackend()
475562
backend._Pwg = SimpleNamespace()

0 commit comments

Comments
 (0)