diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97896ea..19b76bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.8.9 - 2026-10-13
+
+- Keep the audio device stable when changing presets or filter types, avoiding
+ media-player pauses caused by device removal.
+- Avoid processing unused Off bands while keeping all 32 band slots available.
+- Stop analyzer animation and spectrum calculations while frozen, and stop
+ animation while the window is hidden.
+- Use audio-driven analyzer wakeups and coalesced display updates while
+ preserving loudness measurement and background Shell meters.
+
## 0.8.8 - 2026-09-17
- Harden single-instance lock handling against unsafe files and concurrent
diff --git a/data/io.github.bhack.mini-eq.metainfo.xml b/data/io.github.bhack.mini-eq.metainfo.xml
index 91ada6d..fe40e61 100644
--- a/data/io.github.bhack.mini-eq.metainfo.xml
+++ b/data/io.github.bhack.mini-eq.metainfo.xml
@@ -33,11 +33,11 @@
- https://raw.githubusercontent.com/bhack/mini-eq/v0.8.8/docs/screenshots/mini-eq.png
+ https://raw.githubusercontent.com/bhack/mini-eq/v0.8.9/docs/screenshots/mini-eq.png
Adjust sound output with equalizer controls
- https://raw.githubusercontent.com/bhack/mini-eq/v0.8.8/docs/screenshots/mini-eq-dark.png
+ https://raw.githubusercontent.com/bhack/mini-eq/v0.8.9/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
+
+
+
+ - Keep the audio device stable when changing presets or filter types, avoiding media-player pauses caused by device removal.
+ - Avoid processing unused Off bands while keeping all 32 band slots available.
+ - Stop analyzer animation and spectrum calculations while frozen, and stop animation while the window is hidden.
+ - Use audio-driven analyzer wakeups and coalesced display updates while preserving loudness measurement and background Shell meters.
+
+
+
diff --git a/docs/development.md b/docs/development.md
index f1923ea..a85dd5d 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -172,7 +172,7 @@ runtime.
## Local Flatpak Build
The Flatpak manifest uses the GNOME runtime. It does not ship a full PipeWire
-daemon or session manager; it builds only the local PipeWire filter-chain module
+daemon or session manager; it builds the local PipeWire filter-chain and loopback modules
and SPA builtin filter-graph support that Mini EQ loads inside the app process.
It also builds pipewire-gobject for Mini EQ's app-facing PipeWire access and
bundles NumPy and libebur128 for analyzer and live LUFS support.
diff --git a/docs/performance.md b/docs/performance.md
index 52cc058..4ef1b83 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -83,3 +83,20 @@ https://docs.gtk.org/gtk4/running.html
Those environment variables are debugging knobs, not stable application
settings, so do not depend on them from Mini EQ code.
+
+Compare runtime CPU separately from profiler captures. Use a private
+PipeWire/WirePlumber graph, synthetic playback, and temporary application
+settings so the measurements do not change desktop audio or presets. Compare
+Monitor off, live, frozen, and hidden-window states at both 48 and 192 kHz.
+Warm up first, repeat the baseline after the changed build, and record the
+runtime versions and CPU frequency/power policy. Express process CPU as a
+percentage of one logical core; include native audio threads in that total.
+
+Use Sysprof's GTK trace to distinguish frame updates, layout, snapshots, and
+rendering, and its native samples to distinguish PipeWire DSP from Python
+callbacks. Profile startup separately from steady playback: shader compilation
+and font loading can dominate the first frame. `cProfile` is useful for Python
+call attribution, but its elapsed waiting time is not CPU consumption and its
+instrumentation overhead must not be used as a benchmark result. The official
+references are GTK's [drawing model](https://docs.gtk.org/gtk4/drawing-model.html)
+and Python's [profiler guidance](https://docs.python.org/3/library/profile.html).
diff --git a/docs/release.md b/docs/release.md
index 206e83c..bc5290a 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -222,6 +222,19 @@ a focused local loop:
python3 tools/check_headless_pipewire_runtime.py --timeout 35 --cycles 2
```
+For preset topology or virtual-device lifecycle changes, also run the isolated
+PulseAudio/MPRIS smoke on a recent WirePlumber with `linking.pause-playback`
+support. It checks stable device identity, no player pause on preset/type edits
+or normal shutdown, and a positive device-removal control:
+
+```bash
+python3 tools/check_mpris_preset_runtime.py
+```
+
+This requires `pipewire-pulse` and `paplay` in addition to the native headless
+smoke dependencies. It uses a private session bus and audio graph and does not
+change the host's playback or WirePlumber settings.
+
For background portal changes, run one clean-permission Flatpak smoke in a real
GNOME session. Enable **Keep Running in Background**, approve the portal
prompt, enable **Start at Login**, close the window, and confirm the app stays
diff --git a/io.github.bhack.mini-eq.yaml b/io.github.bhack.mini-eq.yaml
index 156e33b..dce95e5 100644
--- a/io.github.bhack.mini-eq.yaml
+++ b/io.github.bhack.mini-eq.yaml
@@ -105,11 +105,13 @@ modules:
- -Dflatpak=enabled
make-args:
- src/modules/libpipewire-module-filter-chain.so
+ - src/modules/libpipewire-module-loopback.so
- spa/plugins/filter-graph/libspa-filter-graph.so
- spa/plugins/filter-graph/libspa-filter-graph-plugin-builtin.so
no-make-install: true
build-commands:
- install -Dm755 src/modules/libpipewire-module-filter-chain.so ${FLATPAK_DEST}/lib/pipewire-0.3/libpipewire-module-filter-chain.so
+ - install -Dm755 src/modules/libpipewire-module-loopback.so ${FLATPAK_DEST}/lib/pipewire-0.3/libpipewire-module-loopback.so
- install -Dm755 spa/plugins/filter-graph/libspa-filter-graph.so ${FLATPAK_DEST}/lib/spa-0.2/filter-graph/libspa-filter-graph.so
- install -Dm755 spa/plugins/filter-graph/libspa-filter-graph-plugin-builtin.so ${FLATPAK_DEST}/lib/spa-0.2/filter-graph/libspa-filter-graph-plugin-builtin.so
sources:
diff --git a/pyproject.toml b/pyproject.toml
index d0ad586..a286603 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mini-eq"
-version = "0.8.8"
+version = "0.8.9"
description = "Compact PipeWire system-wide parametric equalizer for Linux desktops."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/src/mini_eq/analyzer.py b/src/mini_eq/analyzer.py
index d2760a1..6688c55 100644
--- a/src/mini_eq/analyzer.py
+++ b/src/mini_eq/analyzer.py
@@ -51,7 +51,6 @@
ANALYZER_FFT_MIN_SIZE = 8192
ANALYZER_FFT_MAX_SIZE = 32768
ANALYZER_SAMPLE_WIDTH_BYTES = 4
-ANALYZER_QUEUE_WAIT_SECONDS = 0.005
ANALYZER_READER_JOIN_TIMEOUT_SECONDS = 0.2
ANALYZER_DISPLAY_GAIN_MIN = -12.0
ANALYZER_DISPLAY_GAIN_MAX = 32.0
@@ -432,6 +431,8 @@ def __init__(
self.stop_event = threading.Event()
self.stop_event.set()
self.audio_blocks = deque(maxlen=ANALYZER_CAPTURE_QUEUE_BLOCKS)
+ self.audio_blocks_ready = threading.Event()
+ self.spectrum_enabled = True
self.sample_rate = SAMPLE_RATE
self.response_speed = ANALYZER_RESPONSE_DEFAULT
@@ -442,6 +443,10 @@ def client(self):
def set_levels_callback(self, callback: Callable[[list[float]], None] | None) -> None:
self.levels_callback = callback
+ def set_spectrum_enabled(self, enabled: bool) -> None:
+ # Display freeze does not interrupt integrated loudness measurement.
+ self.spectrum_enabled = bool(enabled)
+
def set_loudness_callback(self, callback: Callable[[AnalyzerLoudnessSnapshot | None], None] | None) -> None:
self.loudness_callback = callback
@@ -476,6 +481,7 @@ def start(self) -> bool:
return True
self.audio_blocks.clear()
+ self.audio_blocks_ready.clear()
self.stop_event.clear()
try:
@@ -511,6 +517,7 @@ def stop(self, *, close_stream: bool = False, close_client: bool | None = None)
self.reader_thread = None
self.stop_event.set()
+ self.audio_blocks_ready.set()
self.audio_blocks.clear()
self.emit_loudness_snapshot(None)
@@ -640,6 +647,7 @@ def process_audio_block(self, _stream, block) -> None:
self.sample_rate = float(audio_format.get_rate() or self.sample_rate or SAMPLE_RATE)
data = block.get_data().get_data()
self.audio_blocks.append(interleaved_f32le_bytes_to_channel_payloads(data, audio_format.get_channels()))
+ self.audio_blocks_ready.set()
def read_audio_levels(self) -> None:
pending_samples = array("f")
@@ -655,7 +663,12 @@ def read_audio_levels(self) -> None:
try:
left_payload, right_payload = self.audio_blocks.popleft()
except IndexError:
- self.stop_event.wait(ANALYZER_QUEUE_WAIT_SECONDS)
+ # Clear before rechecking the queue so a producer cannot
+ # enqueue between the empty check and wait unnoticed.
+ self.audio_blocks_ready.clear()
+ if self.audio_blocks or self.stop_event.is_set():
+ continue
+ self.audio_blocks_ready.wait()
continue
if self.loudness_callback is None:
@@ -683,6 +696,12 @@ def read_audio_levels(self) -> None:
loudness_meter = None
loudness_stopped = True
+ if not self.spectrum_enabled:
+ pending_samples = array("f")
+ fft_samples = array("f")
+ smoothed_powers = ()
+ continue
+
pending_samples.extend(stereo_f32le_bytes_to_mono_samples(left_payload, right_payload))
frame_count = analyzer_frame_count(self.sample_rate)
diff --git a/src/mini_eq/deps.py b/src/mini_eq/deps.py
index 767097d..4864e37 100644
--- a/src/mini_eq/deps.py
+++ b/src/mini_eq/deps.py
@@ -54,7 +54,7 @@
)
PIPEWIRE_FILTER_CHAIN_HINT = (
"Ubuntu/Debian: pipewire; Fedora: pipewire; Arch: pipewire. "
- "Flatpak builds bundle only the filter-chain module and SPA builtin filter support."
+ "Flatpak builds bundle the filter-chain and loopback modules and SPA builtin filter support."
)
NUMPY_HINT = (
"Install the package with Python dependencies: python -m pip install mini-eq, or python -m pip install numpy."
@@ -399,6 +399,12 @@ def collect_dependency_checks() -> list[DependencyCheck]:
True,
PIPEWIRE_FILTER_CHAIN_HINT,
),
+ check_pipewire_module(
+ "libpipewire-module-loopback.so",
+ "PipeWire loopback module",
+ True,
+ PIPEWIRE_FILTER_CHAIN_HINT,
+ ),
check_spa_plugin(
"filter-graph/libspa-filter-graph-plugin-builtin.so",
"PipeWire SPA builtin filter graph plugin",
diff --git a/src/mini_eq/filter_chain.py b/src/mini_eq/filter_chain.py
index a763088..9e9dae6 100644
--- a/src/mini_eq/filter_chain.py
+++ b/src/mini_eq/filter_chain.py
@@ -43,6 +43,8 @@ def native_biquad_band_control_values(
sample_rate: float = SAMPLE_RATE,
solo_active: bool = False,
) -> dict[str, float]:
+ if band.filter_type == FILTER_TYPES["Off"] or band.filter_type not in NATIVE_BIQUAD_LABELS:
+ return {}
# The native filter computes its coefficients at the DSP clock rate.
wet = float(eq_enabled and band_is_effective(band, solo_active) and band.filter_type in NATIVE_BIQUAD_LABELS)
controls: dict[str, float] = {}
@@ -67,8 +69,9 @@ def native_biquad_control_values(
sample_rate: float = SAMPLE_RATE,
) -> dict[str, float]:
controls = builtin_biquad_preamp_control_values(preamp_db, eq_enabled)
+ solo_active = bands_have_solo(bands)
for index, band in enumerate(bands[:MAX_BANDS]):
- controls.update(native_biquad_band_control_values(index, band, eq_enabled, sample_rate, bands_have_solo(bands)))
+ controls.update(native_biquad_band_control_values(index, band, eq_enabled, sample_rate, solo_active))
return controls
@@ -77,6 +80,8 @@ def build_native_biquad_nodes(bands: list[EqBand], preamp_db: float, eq_enabled:
for side in ("l", "r"):
nodes.append(build_biquad_node(preamp_node_name(side), preamp_coefficients_by_rate(preamp_db, eq_enabled)))
for index, band in enumerate(bands):
+ if band.filter_type == FILTER_TYPES["Off"] or band.filter_type not in NATIVE_BIQUAD_LABELS:
+ continue
name = biquad_node_name(side, index)
label = NATIVE_BIQUAD_LABELS.get(band.filter_type, "bq_peaking")
controls = native_biquad_band_control_values(index, band, eq_enabled, solo_active=bands_have_solo(bands))
@@ -90,11 +95,11 @@ def build_native_biquad_nodes(bands: list[EqBand], preamp_db: float, eq_enabled:
return "\n".join(nodes)
-def build_native_biquad_links(band_count: int) -> str:
+def build_native_biquad_links(band_indexes: list[int]) -> str:
links: list[str] = []
for side in ("l", "r"):
previous = preamp_node_name(side)
- for index in range(band_count):
+ for index in band_indexes:
name = biquad_node_name(side, index)
links.extend(
[
@@ -115,6 +120,30 @@ def pipewire_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
+def build_eq_input_module_args(virtual_sink_name: str, dsp_input_name: str) -> str:
+ """Keep the player-facing device alive independently of DSP topology."""
+ return f"""{{
+ audio.channels = 2
+ audio.position = [ FL FR ]
+ capture.props = {{
+ node.name = {pipewire_quote(virtual_sink_name)}
+ node.description = {pipewire_quote(VIRTUAL_SINK_DESCRIPTION)}
+ media.class = Audio/Sink
+ state.restore-props = false
+ state.restore-target = false
+ }}
+ playback.props = {{
+ node.name = {pipewire_quote(virtual_sink_name + ".input")}
+ target.object = {pipewire_quote(dsp_input_name)}
+ node.passive = true
+ node.dont-fallback = true
+ node.linger = true
+ state.restore-props = false
+ state.restore-target = false
+ }}
+}}"""
+
+
def spa_float(value: float) -> str:
return f"{float(value):.8g}"
@@ -289,16 +318,27 @@ def build_builtin_biquad_filter_chain_module_args(
filter_output_name: str,
output_sink: str,
native_biquads: bool = False,
+ capture_as_stream: bool = False,
) -> str:
graph_bands = bands[:MAX_BANDS]
band_count = len(graph_bands)
nodes = (build_native_biquad_nodes if native_biquads else build_builtin_biquad_nodes)(
graph_bands, preamp_db, eq_enabled
)
- links = (build_native_biquad_links if native_biquads else build_builtin_biquad_links)(band_count)
+ if native_biquads:
+ band_indexes = [
+ index
+ for index, band in enumerate(graph_bands)
+ if band.filter_type != FILTER_TYPES["Off"] and band.filter_type in NATIVE_BIQUAD_LABELS
+ ]
+ links = build_native_biquad_links(band_indexes)
+ output_index = band_indexes[-1] if band_indexes else None
+ else:
+ links = build_builtin_biquad_links(band_count)
+ output_index = band_count - 1 if band_count else None
rate_property = "" if native_biquads else f" audio.rate = {int(SAMPLE_RATE)}\n"
- output_l = biquad_node_name("l", band_count - 1) if band_count else preamp_node_name("l")
- output_r = biquad_node_name("r", band_count - 1) if band_count else preamp_node_name("r")
+ output_l = biquad_node_name("l", output_index) if output_index is not None else preamp_node_name("l")
+ output_r = biquad_node_name("r", output_index) if output_index is not None else preamp_node_name("r")
return f"""{{
node.description = {pipewire_quote(VIRTUAL_SINK_DESCRIPTION)}
@@ -319,7 +359,8 @@ def build_builtin_biquad_filter_chain_module_args(
capture.props = {{
node.name = {pipewire_quote(virtual_sink_name)}
node.description = {pipewire_quote(VIRTUAL_SINK_DESCRIPTION)}
- media.class = Audio/Sink
+ media.class = {"Stream/Input/Audio" if capture_as_stream else "Audio/Sink"}
+ {"node.autoconnect = false" if capture_as_stream else ""}
state.restore-props = false
state.restore-target = false
audio.channels = 2
diff --git a/src/mini_eq/pipewire_backend.py b/src/mini_eq/pipewire_backend.py
index 5397e42..8f93e93 100644
--- a/src/mini_eq/pipewire_backend.py
+++ b/src/mini_eq/pipewire_backend.py
@@ -485,6 +485,75 @@ def watch_for_audio_sink(
timeout_ms=timeout_ms,
)
+ def wait_for_input_unlinked(self, node_id: int, stream_ids: set[int], timeout_ms: int = 1500) -> bool:
+ """Drain device links on shutdown, not just the metadata write.
+
+ The session manager moves streams asynchronously. Dispatch its graph
+ events before destroying an app-owned device that can trigger MPRIS
+ pause policy. This bounded nested loop is for shutdown only.
+ """
+ self._ensure_connected()
+ loop = self._GLib.MainLoop()
+ pending_id = 0
+ timeout_id = 0
+ done = False
+ detached = False
+ link_handlers: dict[int, int] = {}
+
+ def check() -> bool:
+ nonlocal pending_id, done, detached
+ pending_id = 0
+ if done:
+ return False
+ links = self.list_links()
+ detached = not any(link.input_node_id == node_id for link in links)
+ existing = {node.bound_id for node in self.list_output_streams()}
+ for stream_id in stream_ids & existing:
+ restored = [
+ link for link in links if link.output_node_id == stream_id and link.input_node_id != node_id
+ ]
+ if not restored:
+ detached = False
+ for link in restored:
+ if link.bound_id not in link_handlers:
+ link_handlers[link.bound_id] = self.connect_link_state_changed(link.bound_id, changed)
+ proxy = self._link_proxy_by_bound_id(link.bound_id)
+ if proxy is None or proxy.get_state() not in ("active", "paused"):
+ detached = False
+ if detached:
+ done = True
+ loop.quit()
+ return False
+
+ def changed(*_args) -> None:
+ nonlocal pending_id
+ if not done and pending_id == 0:
+ pending_id = self._GLib.idle_add(check)
+
+ def timeout() -> bool:
+ nonlocal timeout_id, done
+ timeout_id = 0
+ done = True
+ loop.quit()
+ return False
+
+ added = self.connect_object_added(changed)
+ removed = self.connect_object_removed(changed)
+ try:
+ timeout_id = self._GLib.timeout_add(max(1, timeout_ms), timeout)
+ changed()
+ loop.run()
+ finally:
+ done = True
+ self.disconnect_node_manager_handler(added)
+ self.disconnect_node_manager_handler(removed)
+ for handler_id in link_handlers.values():
+ self.disconnect_link_handler(handler_id)
+ for source_id in (pending_id, timeout_id):
+ if source_id:
+ self._GLib.source_remove(source_id)
+ return detached
+
def connect_object_added(self, callback) -> int:
self._ensure_connected()
handler_id = self._GObject.Object.connect(self._registry, "global-added", callback)
@@ -986,11 +1055,14 @@ def _node_param_id_by_name(self, node, name: str) -> int | None:
return None
def load_filter_chain_module(self, arguments: str):
+ return self.load_module(FILTER_CHAIN_MODULE_NAME, arguments)
+
+ def load_module(self, name: str, arguments: str):
self._ensure_connected()
- module = self._core.load_module(FILTER_CHAIN_MODULE_NAME, arguments)
+ module = self._core.load_module(name, arguments)
if module is None:
- raise PipeWireBackendError(f"failed to load PipeWire module: {FILTER_CHAIN_MODULE_NAME}")
+ raise PipeWireBackendError(f"failed to load PipeWire module: {name}")
self._loaded_modules.append(module)
return module
diff --git a/src/mini_eq/pipewire_stream_router.py b/src/mini_eq/pipewire_stream_router.py
index cb1fd3b..0fcc7f1 100644
--- a/src/mini_eq/pipewire_stream_router.py
+++ b/src/mini_eq/pipewire_stream_router.py
@@ -47,6 +47,7 @@ def __init__(
self.accept_stream_events = False
self.event_source_id = 0
self.pending_route_applied_callback = False
+ self.pending_processing_links: dict[int, PipeWireLink] = {}
self.object_added_handler_id = 0
self.object_removed_handler_id = 0
self.link_state_handler_ids: dict[int, int] = {}
@@ -294,6 +295,12 @@ def on_stream_event_idle(self) -> bool:
route_applied = self.pending_route_applied_callback
self.pending_route_applied_callback = False
+ # Registry signals can arrive on the PipeWire loop thread. Binding and
+ # synchronizing link proxies must happen on the GLib thread.
+ pending_links = list(self.pending_processing_links.values())
+ self.pending_processing_links.clear()
+ for link in pending_links:
+ self.track_processing_link_state(link)
self.refresh()
if route_applied and self.route_applied_callback is not None:
self.route_applied_callback()
@@ -319,7 +326,7 @@ def handle_object_added(self, _manager, node) -> None:
return
if self._link_touches_processing_path(link):
- self.track_processing_link_state(link)
+ self.pending_processing_links[link.bound_id] = link
self.schedule_refresh(route_applied=True)
elif self._link_touches_routed_stream(link):
self.schedule_refresh()
@@ -331,6 +338,7 @@ def handle_object_removed(self, _manager, node) -> None:
return
should_refresh = self._link_touches_processing_path(link) or self._link_touches_routed_stream(link)
+ self.pending_processing_links.pop(link.bound_id, None)
self.untrack_processing_link_state(link)
if should_refresh:
self.schedule_refresh()
@@ -355,6 +363,7 @@ def start_monitoring(self, *, require_initial_route: bool = False) -> None:
def stop_monitoring(self) -> None:
self.accept_stream_events = False
+ self.pending_processing_links.clear()
if self.event_source_id > 0:
destroy_glib_source(self.event_source_id)
diff --git a/src/mini_eq/routing.py b/src/mini_eq/routing.py
index 6b71f43..09c649f 100644
--- a/src/mini_eq/routing.py
+++ b/src/mini_eq/routing.py
@@ -35,10 +35,11 @@
sanitize_preset_name,
)
from .filter_chain import (
- build_native_biquad_filter_chain_module_args as build_builtin_biquad_filter_chain_module_args,
+ build_eq_input_module_args,
+ builtin_biquad_preamp_control_values,
)
from .filter_chain import (
- builtin_biquad_preamp_control_values,
+ build_native_biquad_filter_chain_module_args as build_builtin_biquad_filter_chain_module_args,
)
from .filter_chain import native_biquad_band_control_values as builtin_biquad_band_control_values
from .filter_chain import native_biquad_control_values as builtin_biquad_control_values
@@ -112,6 +113,7 @@ def __init__(self, output_sink: str | None) -> None:
self._observed_output_preset_target_snapshot: OutputPresetTargetSnapshot | None = None
self.filter_output_name = f"{self.virtual_sink_name}{FILTER_OUTPUT_SUFFIX}"
self.engine_module = None
+ self.input_module = None
self.engine_start_watch = None
self.engine_start_pending = False
self.filter_node_id: int | None = None
@@ -147,6 +149,7 @@ def __init__(self, output_sink: str | None) -> None:
self.stream_router: PipeWireStreamRouter | None = None
self.output_analyzer: OutputSpectrumAnalyzer | None = None
self.analyzer_response_speed = ANALYZER_RESPONSE_DEFAULT
+ self.analyzer_spectrum_enabled = True
if not self.is_valid_output_sink(self.output_sink):
raise AudioBackendError("output sink cannot be a Mini EQ virtual sink")
@@ -188,6 +191,11 @@ def set_analyzer_loudness_callback(
if self.output_analyzer is not None:
self.output_analyzer.set_loudness_callback(callback)
+ def set_analyzer_spectrum_enabled(self, enabled: bool) -> None:
+ self.analyzer_spectrum_enabled = bool(enabled)
+ if self.output_analyzer is not None:
+ self.output_analyzer.set_spectrum_enabled(enabled)
+
def list_sinks(self) -> list[PipeWireNode]:
return self.output_backend.list_audio_sinks()
@@ -328,6 +336,7 @@ def ensure_output_analyzer(self) -> OutputSpectrumAnalyzer:
self.output_analyzer.set_levels_callback(self.analyzer_levels_callback)
self.output_analyzer.set_loudness_callback(self.analyzer_loudness_callback)
self.output_analyzer.set_response_speed(self.analyzer_response_speed)
+ self.output_analyzer.set_spectrum_enabled(getattr(self, "analyzer_spectrum_enabled", True))
return self.output_analyzer
def prepare_output_analyzer(self) -> bool:
@@ -720,11 +729,23 @@ def build_filter_chain_module_args(self) -> str:
bands=self.bands,
preamp_db=self.preamp_db,
eq_enabled=self.eq_enabled,
- virtual_sink_name=self.virtual_sink_name,
+ virtual_sink_name=self.dsp_input_name,
filter_output_name=self.filter_output_name,
output_sink=self.output_sink,
+ capture_as_stream=True,
)
+ @property
+ def dsp_input_name(self) -> str:
+ return f"{self.virtual_sink_name}.dsp"
+
+ def ensure_input_module(self) -> None:
+ if getattr(self, "input_module", None) is None:
+ self.input_module = self.output_backend.load_module(
+ "libpipewire-module-loopback",
+ build_eq_input_module_args(self.virtual_sink_name, self.dsp_input_name),
+ )
+
def cancel_pending_engine_start(self) -> None:
self._engine_start_token = None
watch = getattr(self, "engine_start_watch", None)
@@ -747,6 +768,7 @@ def start_engine(
if getattr(self, "engine_start_pending", False):
return
+ self.ensure_input_module()
self.engine_module = self.output_backend.load_filter_chain_module(self.build_filter_chain_module_args())
self.engine_start_pending = True
start_token = object()
@@ -758,8 +780,14 @@ def fail(exc: Exception) -> None:
self._engine_start_token = None
self.engine_start_watch = None
self.engine_start_pending = False
+ module = self.engine_module
self.engine_module = None
self.filter_node_id = None
+ if module is not None:
+ try:
+ self.output_backend.unload_filter_chain_module(module)
+ except Exception:
+ pass
try:
self.output_backend.sync()
except Exception:
@@ -779,7 +807,7 @@ def on_sink_ready(sink: PipeWireNode | None) -> None:
return
if sink is None:
- fail(RuntimeError(f"filter-chain did not create {self.virtual_sink_name}"))
+ fail(RuntimeError(f"filter-chain did not create {self.dsp_input_name}"))
return
# A preset/type edit during creation makes this topology obsolete.
@@ -813,9 +841,32 @@ def on_sink_ready(sink: PipeWireNode | None) -> None:
on_ready()
try:
- self.engine_start_watch = self.output_backend.watch_for_audio_sink(
- self.virtual_sink_name,
- on_sink_ready,
+
+ def on_dsp_ready(node: PipeWireNode | None) -> None:
+ if self._engine_start_token is not start_token:
+ return
+ if node is None:
+ on_sink_ready(None)
+ return
+
+ def on_input_ready(sink: PipeWireNode | None) -> None:
+ if self._engine_start_token is not start_token:
+ return
+ if sink is None:
+ fail(RuntimeError(f"loopback did not create {self.virtual_sink_name}"))
+ else:
+ on_sink_ready(node)
+
+ try:
+ self.engine_start_watch = self.output_backend.watch_for_audio_sink(
+ self.virtual_sink_name, on_input_ready, timeout_ms=3000
+ )
+ except Exception as exc:
+ fail(exc)
+
+ self.engine_start_watch = self.output_backend.watch_for_node(
+ lambda node: node.node_name == self.dsp_input_name,
+ on_dsp_ready,
timeout_ms=3000,
)
except Exception as exc:
@@ -879,27 +930,23 @@ def restart_engine(self) -> None:
if not was_running:
return
- stream_router = self.stream_router if self.routed else None
- if stream_router is not None:
- stream_router.stop_monitoring()
- try:
- stream_router.restore_output_streams()
- except Exception as exc:
- stream_router.emit_warning(exc)
-
+ # Only stream nodes disappear here. The player-facing Audio/Sink stays
+ # alive, so WirePlumber's device-removal MPRIS policy cannot pause players.
self.stop_engine(announce=False)
- def on_ready() -> None:
- if stream_router is not None:
- stream_router.start_monitoring(require_initial_route=True)
-
def on_error(exc: Exception) -> None:
- if stream_router is not None:
- stream_router.emit_warning(exc)
- else:
- self.emit_status(str(exc))
+ # Keep the stable sink alive, but don't strand music in a broken DSP.
+ if self.routed:
+ try:
+ self.route_system_audio(False, refresh_output=False)
+ except Exception as restore_exc:
+ self.emit_status(f"filter-chain routing restore failed: {restore_exc}")
+ self.emit_status(str(exc))
- self.start_engine(on_ready=on_ready, on_error=on_error)
+ try:
+ self.start_engine(on_error=on_error)
+ except Exception as exc:
+ on_error(exc)
def set_filter_controls(self, controls: dict[str, float]) -> None:
if self.filter_node_id is None or not self.running:
@@ -912,6 +959,9 @@ def set_filter_controls(self, controls: dict[str, float]) -> None:
self.restart_engine()
return
+ if not controls:
+ return
+
try:
self.output_backend.set_node_params(self.filter_node_id, controls)
except Exception as exc:
@@ -1127,6 +1177,7 @@ def on_engine_error(exc: Exception) -> None:
raise
def shutdown(self) -> None:
+ routed_stream_ids = set(getattr(self.stream_router, "routed_stream_ids", ()))
self.shutting_down = True
self.status_callback = None
self.outputs_changed_callback = None
@@ -1146,6 +1197,13 @@ def shutdown(self) -> None:
pass
finally:
try:
+ # Remove internal streams first. Keep the device until the
+ # session manager has linked restored playback to its output.
+ self.stop_engine(announce=False)
+ if getattr(self, "input_module", None) is not None:
+ sink = self.output_backend.audio_sink_by_name(self.virtual_sink_name)
+ if sink is not None:
+ self.output_backend.wait_for_input_unlinked(sink.bound_id, routed_stream_ids)
if self.stream_router is not None:
self.stream_router.close()
if self.output_analyzer is not None:
@@ -1155,6 +1213,13 @@ def shutdown(self) -> None:
self.stop_engine(announce=False)
except Exception:
pass
+ input_module = getattr(self, "input_module", None)
+ self.input_module = None
+ if input_module is not None:
+ try:
+ self.output_backend.unload_filter_chain_module(input_module)
+ except Exception:
+ pass
try:
self.output_backend.close()
except Exception:
diff --git a/src/mini_eq/window.py b/src/mini_eq/window.py
index 9a23eb3..18a7353 100644
--- a/src/mini_eq/window.py
+++ b/src/mini_eq/window.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import threading
import time
import gi
@@ -180,9 +181,13 @@ def __init__(
self.analyzer_frozen = False
self.analyzer_smoothing = 0.40
self.analyzer_preview_source_id = 0
+ self.analyzer_background_decay_source_id = 0
self.analyzer_preview_uses_tick_callback = False
self.analyzer_preview_last_tick_time = 0.0
self.analyzer_levels = [0.0] * ANALYZER_BIN_COUNT
+ self.analyzer_levels_lock = threading.Lock()
+ self.analyzer_pending_levels: tuple[float, ...] | None = None
+ self.analyzer_levels_idle_pending = False
self.analyzer_db_floor = ANALYZER_DB_FLOOR
self.analyzer_display_gain_db = ANALYZER_DISPLAY_GAIN_DEFAULT
self.analyzer_loudness_snapshot = None
@@ -264,6 +269,8 @@ def __init__(
self.install_css()
self.sync_appearance_css_class()
self.build_window_content(auto_route)
+ self.connect("map", self.on_analyzer_visibility_changed)
+ self.connect("unmap", self.on_analyzer_visibility_changed)
self.style_dark_notify_handler_id = self.style_manager.connect(
"notify::dark", self.on_style_manager_dark_changed
)
diff --git a/src/mini_eq/window_analyzer.py b/src/mini_eq/window_analyzer.py
index 4813b3a..bf432d1 100644
--- a/src/mini_eq/window_analyzer.py
+++ b/src/mini_eq/window_analyzer.py
@@ -26,6 +26,7 @@
ANALYZER_ATTACK_SMOOTHING_MAX = 0.25
ANALYZER_PIXEL_REDRAW_THRESHOLD = 1.0
CONTROL_ANALYZER_EMIT_INTERVAL_SECONDS = 0.10
+ANALYZER_BACKGROUND_DECAY_INTERVAL_MS = 100
LOUDNESS_METER_MIN_LUFS = -60.0
LOUDNESS_METER_MAX_LUFS = 0.0
@@ -188,7 +189,8 @@ def start_analyzer_preview(self) -> None:
self.refresh_after_monitor_state_changed(monitor_visibility_changed=True)
return
- self.start_analyzer_preview_clock()
+ self.sync_analyzer_spectrum_enabled()
+ self.sync_analyzer_preview_clock()
def stop_analyzer_preview(self, *, stop_backend: bool = True) -> None:
if stop_backend:
@@ -203,8 +205,14 @@ def stop_analyzer_preview(self, *, stop_backend: bool = True) -> None:
destroy_glib_source(self.analyzer_preview_source_id)
self.analyzer_preview_source_id = 0
self.analyzer_preview_uses_tick_callback = False
+ decay_source_id = getattr(self, "analyzer_background_decay_source_id", 0)
+ if decay_source_id > 0:
+ destroy_glib_source(decay_source_id)
+ self.analyzer_background_decay_source_id = 0
def start_analyzer_preview_clock(self) -> None:
+ if self.analyzer_preview_source_id > 0:
+ return
add_tick_callback = getattr(self.analyzer_area, "add_tick_callback", None)
if callable(add_tick_callback):
self.analyzer_preview_source_id = add_tick_callback(self.on_analyzer_preview_frame)
@@ -214,6 +222,67 @@ def start_analyzer_preview_clock(self) -> None:
self.analyzer_preview_source_id = GLib.timeout_add(ANALYZER_PREVIEW_INTERVAL_MS, self.on_analyzer_preview_tick)
self.analyzer_preview_uses_tick_callback = False
+ def sync_analyzer_spectrum_enabled(self) -> None:
+ callback = getattr(self.controller, "set_analyzer_spectrum_enabled", None)
+ if callable(callback):
+ callback(not self.analyzer_frozen)
+
+ def sync_analyzer_preview_clock(self) -> None:
+ if (
+ not getattr(self, "ui_shutting_down", False)
+ and self.analyzer_enabled
+ and not self.analyzer_frozen
+ and self.analyzer_area_is_drawable()
+ ):
+ decay_source_id = getattr(self, "analyzer_background_decay_source_id", 0)
+ if decay_source_id > 0:
+ destroy_glib_source(decay_source_id)
+ self.analyzer_background_decay_source_id = 0
+ self.start_analyzer_preview_clock()
+ else:
+ self.stop_analyzer_preview(stop_backend=False)
+
+ def on_analyzer_visibility_changed(self, *_args: object) -> None:
+ self.sync_analyzer_preview_clock()
+ self.schedule_background_analyzer_decay()
+ self.queue_analyzer_draw(force=True)
+
+ def schedule_background_analyzer_decay(self) -> None:
+ if (
+ getattr(self, "ui_shutting_down", False)
+ or not self.analyzer_enabled
+ or self.analyzer_frozen
+ or not hasattr(self, "analyzer_area")
+ or self.analyzer_area_is_drawable()
+ or not any(level > 0.01 for level in self.analyzer_levels)
+ or getattr(self, "analyzer_background_decay_source_id", 0) > 0
+ ):
+ return
+ # A hidden window has no animation clock. Keep the Shell meter's
+ # stale-data decay at its 10 Hz publication cadence instead.
+ self.analyzer_background_decay_source_id = GLib.timeout_add(
+ ANALYZER_BACKGROUND_DECAY_INTERVAL_MS, self.on_background_analyzer_decay
+ )
+
+ def on_background_analyzer_decay(self) -> bool:
+ if (
+ self.ui_shutting_down
+ or not self.analyzer_enabled
+ or self.analyzer_frozen
+ or self.analyzer_area_is_drawable()
+ ):
+ self.analyzer_background_decay_source_id = 0
+ return False
+ now = GLib.get_monotonic_time() / 1_000_000.0
+ if now - self.analyzer_last_frame_time < 0.18:
+ return True
+ keep_running = self.decay_analyzer_levels(
+ now, interval_scale=ANALYZER_BACKGROUND_DECAY_INTERVAL_MS / 1000.0 / ANALYZER_PREVIEW_INTERVAL_S
+ )
+ if not keep_running:
+ self.analyzer_background_decay_source_id = 0
+ return keep_running
+
def on_analyzer_preview_frame(self, _widget: Gtk.Widget, frame_clock: object) -> bool:
get_frame_time = getattr(frame_clock, "get_frame_time", None)
now = (
@@ -312,10 +381,24 @@ def analyzer_pixels_changed(self, current: tuple[float, ...]) -> bool:
return False
def on_analyzer_levels(self, levels: list[float]) -> None:
- if self.ui_shutting_down:
+ if self.ui_shutting_down or self.analyzer_frozen:
return
- GLib.idle_add(self.on_analyzer_levels_idle, tuple(levels))
+ with self.analyzer_levels_lock:
+ self.analyzer_pending_levels = tuple(levels)
+ if self.analyzer_levels_idle_pending:
+ return
+ self.analyzer_levels_idle_pending = True
+ GLib.idle_add(self.dispatch_pending_analyzer_levels)
+
+ def dispatch_pending_analyzer_levels(self) -> bool:
+ with self.analyzer_levels_lock:
+ levels = self.analyzer_pending_levels
+ self.analyzer_pending_levels = None
+ self.analyzer_levels_idle_pending = False
+ if levels is not None:
+ self.on_analyzer_levels_idle(levels)
+ return False
def on_analyzer_levels_idle(self, levels: tuple[float, ...]) -> bool:
if self.ui_shutting_down:
@@ -340,6 +423,7 @@ def on_analyzer_levels_idle(self, levels: tuple[float, ...]) -> bool:
now = GLib.get_monotonic_time() / 1_000_000.0
self.analyzer_last_frame_time = now
+ self.schedule_background_analyzer_decay()
self.queue_analyzer_draw()
self.maybe_emit_control_analyzer_levels_changed(now)
return False
@@ -377,6 +461,11 @@ def on_analyzer_preview_tick(self, now: float | None = None) -> bool:
self.analyzer_preview_uses_tick_callback = False
return False
+ if self.analyzer_frozen or not self.analyzer_area_is_drawable():
+ self.analyzer_preview_source_id = 0
+ self.analyzer_preview_uses_tick_callback = False
+ return False
+
if not self.analyzer_enabled and not any(level > 0.01 for level in self.analyzer_levels):
self.analyzer_preview_source_id = 0
self.analyzer_preview_uses_tick_callback = False
@@ -389,18 +478,21 @@ def on_analyzer_preview_tick(self, now: float | None = None) -> bool:
if self.analyzer_enabled and not self.analyzer_frozen and age < 0.18:
return True
+ self.decay_analyzer_levels(now)
+ return True
+
+ def decay_analyzer_levels(self, now: float, *, interval_scale: float = 1.0) -> bool:
still_visible = False
for index, current in enumerate(self.analyzer_levels):
- decayed = current * (0.78 if self.analyzer_enabled else 0.68)
+ decayed = current * (0.78 if self.analyzer_enabled else 0.68) ** interval_scale
if decayed > 0.01:
still_visible = True
- self.analyzer_levels[index] = decayed
+ self.analyzer_levels[index] = decayed if decayed > 0.01 else 0.0
- if still_visible:
- self.queue_analyzer_draw()
- self.maybe_emit_control_analyzer_levels_changed(now)
+ self.queue_analyzer_draw()
+ self.maybe_emit_control_analyzer_levels_changed(now)
- return True
+ return still_visible
def _sync_monitor_controls_unlocked(self) -> None:
set_switch_confirmed_state(self.analyzer_switch, self.analyzer_enabled)
@@ -496,6 +588,8 @@ def on_analyzer_freeze_changed(self, switch: Gtk.Switch, state: object | None) -
return False
self.analyzer_frozen = frozen
+ self.sync_analyzer_spectrum_enabled()
+ self.sync_analyzer_preview_clock()
self.refresh_after_monitor_state_changed()
return True
diff --git a/tests/test_mini_eq_analyzer.py b/tests/test_mini_eq_analyzer.py
index b0c28ba..63ee1a0 100644
--- a/tests/test_mini_eq_analyzer.py
+++ b/tests/test_mini_eq_analyzer.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import math
+import threading
from array import array
import pytest
@@ -583,3 +584,74 @@ def levels_callback(_levels: list[float]) -> None:
assert snapshots == [analyzer.AnalyzerLoudnessSnapshot(-20.0, -18.0, -17.0)]
assert len(created_meters) == 1
assert created_meters[0].closed is True
+
+
+def test_analyzer_empty_reader_waits_for_data_and_stop_wakes_it(monkeypatch) -> None:
+ spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None)
+ entered = threading.Event()
+ original_wait = spectrum.audio_blocks_ready.wait
+
+ def wait_for_audio():
+ entered.set()
+ return original_wait()
+
+ monkeypatch.setattr(spectrum.audio_blocks_ready, "wait", wait_for_audio)
+ spectrum.stop_event.clear()
+ reader = threading.Thread(target=spectrum.read_audio_levels, daemon=True)
+ spectrum.reader_thread = reader
+ reader.start()
+ try:
+ assert entered.wait(1)
+ finally:
+ spectrum.stop()
+ assert not reader.is_alive()
+
+
+def test_frozen_analyzer_preserves_loudness_without_fft(monkeypatch) -> None:
+ snapshots = []
+ fft_calls = []
+ spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None)
+ spectrum.stop_event.clear()
+ spectrum.set_spectrum_enabled(False)
+ spectrum.audio_blocks.append((array("f", [0.2] * 2048).tobytes(), array("f", [0.1] * 2048).tobytes()))
+
+ class Meter:
+ def close(self):
+ pass
+
+ snapshot = analyzer.AnalyzerLoudnessSnapshot(-20, -18, -17)
+
+ def on_loudness(value):
+ snapshots.append(value)
+ spectrum.stop_event.set()
+
+ spectrum.set_loudness_callback(on_loudness)
+ monkeypatch.setattr(spectrum, "create_loudness_meter", Meter)
+ monkeypatch.setattr(spectrum, "feed_loudness_meter", lambda *_args: True)
+ monkeypatch.setattr(spectrum, "read_loudness_snapshot", lambda _meter: snapshot)
+ monkeypatch.setattr(analyzer, "samples_to_log_band_powers", lambda *_args, **_kwargs: fft_calls.append(True))
+ spectrum.read_audio_levels()
+ assert snapshots == [snapshot]
+ assert fft_calls == []
+
+
+def test_analyzer_rechecks_queue_when_data_arrives_during_empty_clear(monkeypatch) -> None:
+ spectrum = analyzer.OutputSpectrumAnalyzer("test_sink", None, lambda _message: None)
+ spectrum.stop_event.clear()
+ original_clear = spectrum.audio_blocks_ready.clear
+ frames = []
+
+ def enqueue_during_clear():
+ original_clear()
+ payload = array("f", [0.1] * 2048).tobytes()
+ spectrum.audio_blocks.append((payload, payload))
+ spectrum.audio_blocks_ready.set()
+
+ def on_levels(levels):
+ frames.append(levels)
+ spectrum.stop_event.set()
+
+ monkeypatch.setattr(spectrum.audio_blocks_ready, "clear", enqueue_during_clear)
+ spectrum.set_levels_callback(on_levels)
+ spectrum.read_audio_levels()
+ assert frames
diff --git a/tests/test_mini_eq_filter_chain.py b/tests/test_mini_eq_filter_chain.py
index de43e1c..254437e 100644
--- a/tests/test_mini_eq_filter_chain.py
+++ b/tests/test_mini_eq_filter_chain.py
@@ -9,6 +9,34 @@ def test_pipewire_quote_escapes_module_argument_strings() -> None:
assert filter_chain.pipewire_quote('a"b\\c') == '"a\\"b\\\\c"'
+def test_stable_input_has_no_hardware_fallback_or_fixed_sample_rate() -> None:
+ args = filter_chain.build_eq_input_module_args("mini_eq_sink", "mini_eq_sink.dsp")
+ assert 'node.name = "mini_eq_sink"' in args
+ assert 'target.object = "mini_eq_sink.dsp"' in args
+ assert "media.class = Audio/Sink" in args
+ assert "node.dont-fallback = true" in args
+ assert "node.linger = true" in args
+ assert "node.passive = true" in args
+ assert "audio.rate" not in args
+
+
+def test_dsp_input_is_a_stream_not_a_removable_device() -> None:
+ args = filter_chain.build_native_biquad_filter_chain_module_args(
+ bands=core.default_eq_bands(),
+ preamp_db=0,
+ eq_enabled=True,
+ virtual_sink_name="mini_eq_sink.dsp",
+ filter_output_name="mini_eq_sink_output",
+ output_sink="speakers",
+ capture_as_stream=True,
+ )
+ assert "media.class = Stream/Input/Audio" in args
+ assert "media.class = Audio/Sink" not in args
+ assert "node.autoconnect = false" in args
+ assert 'target.object = "speakers"' in args
+ assert "audio.rate" not in args
+
+
def test_native_biquads_follow_graph_rate_and_have_true_bypass() -> None:
bands = [core.EqBand(core.FILTER_TYPES["Lo-pass"], 1000.0, 0.0, 1.4)]
args = filter_chain.build_native_biquad_filter_chain_module_args(
@@ -45,6 +73,46 @@ def test_native_biquad_solo_and_mute_use_dry_path() -> None:
assert filter_chain.native_biquad_control_values(bands, 0, True)["band_r_1:Gain 1"] == 0
+def test_native_graph_skips_off_slots_without_renumbering() -> None:
+ bands = core.inactive_eq_bands()
+ bands[2] = core.EqBand(core.FILTER_TYPES["Lo-pass"], 1000, 0, 1)
+ bands[7] = core.EqBand(core.FILTER_TYPES["Bell"], 2000, 3, 1, mute=True)
+ args = filter_chain.build_native_biquad_filter_chain_module_args(
+ bands=bands,
+ preamp_db=-3,
+ eq_enabled=True,
+ virtual_sink_name="mini_eq_sink",
+ filter_output_name="mini_eq_sink_output",
+ output_sink="test",
+ )
+ for side in ("l", "r"):
+ assert f'output = "preamp_{side}:Out" input = "band_{side}_2_filter:In"' in args
+ assert f'output = "band_{side}_2:Out" input = "band_{side}_7_filter:In"' in args
+ assert f'"band_{side}_7:Out"' in args
+ assert f"name = band_{side}_7_filter" in args
+ assert "band_l_0" not in args
+ assert "band_l_31" not in args
+ controls = filter_chain.native_biquad_control_values(bands, -3, True)
+ assert controls["band_l_2:Gain 1"] == 1
+ assert controls["band_l_7:Gain 1"] == 0
+ assert not any("band_l_0" in key or "band_l_31" in key for key in controls)
+ assert filter_chain.native_biquad_band_control_values(31, bands[31], True) == {}
+ assert len(bands) == core.MAX_BANDS
+
+
+def test_native_all_off_graph_connects_preamp_to_outputs() -> None:
+ args = filter_chain.build_native_biquad_filter_chain_module_args(
+ bands=core.inactive_eq_bands(),
+ preamp_db=-6,
+ eq_enabled=True,
+ virtual_sink_name="mini_eq_sink",
+ filter_output_name="mini_eq_sink_output",
+ output_sink="test",
+ )
+ assert "band_l_" not in args
+ assert 'outputs = [ "preamp_l:Out" "preamp_r:Out" ]' in args
+
+
def test_builtin_biquad_filter_chain_uses_pipewire_raw_biquads() -> None:
bands = [
core.EqBand(core.FILTER_TYPES["Bell"], 1000.0, 6.0, 1.4),
diff --git a/tests/test_mini_eq_pipewire_backend.py b/tests/test_mini_eq_pipewire_backend.py
index f367189..15237c8 100644
--- a/tests/test_mini_eq_pipewire_backend.py
+++ b/tests/test_mini_eq_pipewire_backend.py
@@ -8,6 +8,60 @@
from tests._mini_eq_imports import pipewire_routes as pw_routes
+@pytest.mark.parametrize("initial", ["old-link", "gap"])
+@pytest.mark.parametrize("restored_state", ["active", "paused", "error"])
+def test_shutdown_wait_requires_a_restored_link_and_cleans_up(initial, restored_state) -> None:
+ backend = pw_backend.PipeWireBackend.__new__(pw_backend.PipeWireBackend)
+ links = [pw_backend.PipeWireLink(1, 7, 42, False, False)] if initial == "old-link" else []
+ events = {}
+ disconnected = []
+
+ class Loop:
+ stopped = False
+
+ def quit(self):
+ self.stopped = True
+
+ def run(self):
+ glib.idle()
+ # An unlinked gap is not a completed restore.
+ assert not self.stopped
+ links[:] = [pw_backend.PipeWireLink(2, 7, 43, False, False)]
+ events[10]()
+ glib.idle()
+ if not self.stopped:
+ glib.timeout()
+
+ class GLib:
+ MainLoop = Loop
+
+ def idle_add(self, callback):
+ self.idle = callback
+ return 88
+
+ def timeout_add(self, milliseconds, callback):
+ assert milliseconds == 1500
+ self.timeout = callback
+ return 77
+
+ def source_remove(self, source_id):
+ pass
+
+ glib = GLib()
+ backend._GLib = glib
+ backend._ensure_connected = lambda: None
+ backend.list_links = lambda: list(links)
+ backend.list_output_streams = lambda: [SimpleNamespace(bound_id=7)]
+ backend.connect_object_added = lambda callback: events.setdefault(10, callback) and 10
+ backend.connect_object_removed = lambda callback: events.setdefault(11, callback) and 11
+ backend.disconnect_node_manager_handler = disconnected.append
+ backend.connect_link_state_changed = lambda _link_id, _callback: 12
+ backend.disconnect_link_handler = disconnected.append
+ backend._link_proxy_by_bound_id = lambda _link_id: SimpleNamespace(get_state=lambda: restored_state)
+ assert backend.wait_for_input_unlinked(42, {7}) is (restored_state != "error")
+ assert set(disconnected) == {10, 11, 12}
+
+
class FakeCore:
calls: int = 0
diff --git a/tests/test_mini_eq_pipewire_stream_router.py b/tests/test_mini_eq_pipewire_stream_router.py
index af6b375..5c9443e 100644
--- a/tests/test_mini_eq_pipewire_stream_router.py
+++ b/tests/test_mini_eq_pipewire_stream_router.py
@@ -488,11 +488,12 @@ def test_pipewire_router_reapplies_controls_when_virtual_sink_link_appears(monke
assert router.event_source_id == 321
assert len(scheduled_callbacks) == 1
- assert 5092 in backend.link_state_handlers
+ assert backend.link_state_handlers == {}
keep_source = scheduled_callbacks[0]()
assert keep_source is False
+ assert 5092 in backend.link_state_handlers
assert backend.moves == [(1, "mini_eq_sink")]
assert applied == ["apply"]
@@ -608,6 +609,8 @@ def test_pipewire_router_tracks_internal_output_links(monkeypatch: pytest.Monkey
router.handle_object_added(None, make_link(93, output_node_id=internal_output.bound_id, input_node_id=22))
assert router.event_source_id == 321
+ assert backend.link_state_handlers == {}
+ scheduled_callbacks[0]()
assert 5093 in backend.link_state_handlers
diff --git a/tests/test_mini_eq_routing.py b/tests/test_mini_eq_routing.py
index 691e46c..2f07ec6 100644
--- a/tests/test_mini_eq_routing.py
+++ b/tests/test_mini_eq_routing.py
@@ -1055,8 +1055,12 @@ def set_loudness_callback(self, callback) -> None:
def set_response_speed(self, speed: float) -> None:
self.response_speed = speed
+ def set_spectrum_enabled(self, enabled: bool) -> None:
+ self.spectrum_enabled = enabled
+
monkeypatch.setattr(routing, "OutputSpectrumAnalyzer", FakeAnalyzer)
controller.emit_status = lambda _message: None
+ controller.analyzer_spectrum_enabled = False
analyzer_instance = routing.SystemWideEqController.ensure_output_analyzer(controller)
@@ -1064,6 +1068,7 @@ def set_response_speed(self, speed: float) -> None:
assert analyzer_instance.output_sink_name == "speakers"
assert analyzer_instance.levels_callback is controller.analyzer_levels_callback
assert analyzer_instance.loudness_callback is controller.analyzer_loudness_callback
+ assert analyzer_instance.spectrum_enabled is False
def test_enabling_prepared_analyzer_does_not_restart_running_engine() -> None:
@@ -1359,9 +1364,9 @@ def close(self) -> None:
"stop-monitor",
("target", "speakers"),
("disable", False),
- "close-router",
"unload-engine",
"sync-engine",
+ "close-router",
"close-backend",
]
assert controller.routed is False
@@ -1565,15 +1570,25 @@ class FakeBackend:
def __init__(self) -> None:
self.callback = None
+ def load_module(self, name, arguments):
+ assert name == "libpipewire-module-loopback"
+ return object()
+
def load_filter_chain_module(self, arguments: str):
calls.append(f"load:{arguments}")
return module
- def watch_for_audio_sink(self, sink_name: str, callback, *, timeout_ms: int):
- calls.append(f"wait:{sink_name}:{timeout_ms}")
+ def watch_for_node(self, predicate, callback, *, timeout_ms: int):
+ assert predicate(make_node(42, "mini_eq_sink.dsp"))
+ assert not predicate(make_node(43, "mini_eq_sink"))
+ calls.append(f"wait:mini_eq_sink.dsp:{timeout_ms}")
self.callback = callback
return FakeWatch()
+ def watch_for_audio_sink(self, name, callback, **kwargs):
+ callback(make_node(43, name))
+ return FakeWatch()
+
backend = FakeBackend()
controller.engine_module = None
controller.engine_start_pending = False
@@ -1593,7 +1608,7 @@ def watch_for_audio_sink(self, sink_name: str, callback, *, timeout_ms: int):
assert calls == [
"load:module args",
- "wait:mini_eq_sink:3000",
+ "wait:mini_eq_sink.dsp:3000",
"status:filter-chain PipeWire EQ ready: mini_eq_sink -> speakers",
"apply",
]
@@ -1602,6 +1617,49 @@ def watch_for_audio_sink(self, sink_name: str, callback, *, timeout_ms: int):
assert controller.running is True
+def test_input_module_is_loaded_once_across_dsp_replacements() -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ calls = []
+ module = object()
+
+ class Backend:
+ def load_module(self, name, arguments):
+ calls.append((name, arguments))
+ return module
+
+ controller.virtual_sink_name = "mini_eq_sink"
+ controller.output_backend = Backend()
+ controller.ensure_input_module()
+ controller.ensure_input_module()
+ assert len(calls) == 1
+ assert calls[0][0] == "libpipewire-module-loopback"
+ assert 'target.object = "mini_eq_sink.dsp"' in calls[0][1]
+ assert controller.input_module is module
+
+
+def test_stop_engine_preserves_the_input_device() -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ dsp = object()
+ device = object()
+ unloaded = []
+
+ class Backend:
+ def unload_filter_chain_module(self, module):
+ unloaded.append(module)
+
+ def sync(self):
+ pass
+
+ controller.engine_module = dsp
+ controller.input_module = device
+ controller.output_backend = Backend()
+ controller.running = True
+ controller.stop_engine(announce=False)
+ assert unloaded == [dsp]
+ assert controller.input_module is device
+ assert not controller.running
+
+
@pytest.mark.parametrize("replacement_fails", [False, True])
def test_start_engine_preserves_completion_across_pending_type_changes(replacement_fails: bool) -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
@@ -1616,6 +1674,9 @@ def cancel(self):
pass
class FakeBackend:
+ def load_module(self, name, arguments):
+ return object()
+
def load_filter_chain_module(self, arguments):
return object()
@@ -1625,10 +1686,14 @@ def unload_filter_chain_module(self, module):
def sync(self):
pass
- def watch_for_audio_sink(self, name, callback, **kwargs):
+ def watch_for_node(self, predicate, callback, **kwargs):
callbacks.append(callback)
return FakeWatch()
+ def watch_for_audio_sink(self, name, callback, **kwargs):
+ callback(make_node(41, name))
+ return FakeWatch()
+
def set_node_params(self, node_id, controls):
pass
@@ -1669,7 +1734,7 @@ def set_node_params(self, node_id, controls):
callbacks[2](None if replacement_fails else make_node(44, "mini_eq_sink"))
assert ready == ([] if replacement_fails else [44])
assert monitored == ([] if replacement_fails else [44])
- assert errors == (["filter-chain did not create mini_eq_sink"] if replacement_fails else [])
+ assert errors == (["filter-chain did not create mini_eq_sink.dsp"] if replacement_fails else [])
assert controller.running is not replacement_fails
@@ -1818,6 +1883,24 @@ def test_filter_control_param_change_ignores_recent_local_control_update(
assert controller.applying_filter_control_verification is False
+@pytest.mark.parametrize("type_changed", [False, True])
+def test_empty_off_band_controls_only_rebuild_when_topology_changed(type_changed: bool) -> None:
+ controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
+ controller.running = True
+ controller.filter_node_id = 42
+ controller.bands = core.inactive_eq_bands()
+ built_types = [band.filter_type for band in controller.bands]
+ if type_changed:
+ built_types[31] = core.FILTER_TYPES["Bell"]
+ controller._engine_band_types = tuple(built_types)
+ calls = []
+ controller.restart_engine = lambda: calls.append("restart")
+ # An empty control set must not reach the backend, including on a rebuild.
+ controller.output_backend = object()
+ controller.set_filter_controls({})
+ assert calls == (["restart"] if type_changed else [])
+
+
def test_filter_control_verification_echo_does_not_schedule_another_verification(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -1867,12 +1950,15 @@ class FakeBackend:
def __init__(self) -> None:
self.callback = None
+ def load_module(self, name, arguments):
+ return object()
+
def load_filter_chain_module(self, arguments: str):
calls.append(f"load:{arguments}")
return module
- def watch_for_audio_sink(self, sink_name: str, callback, *, timeout_ms: int):
- calls.append(f"wait:{sink_name}:{timeout_ms}")
+ def watch_for_node(self, predicate, callback, *, timeout_ms: int):
+ calls.append(f"wait:mini_eq_sink.dsp:{timeout_ms}")
self.callback = callback
return FakeWatch()
@@ -1895,8 +1981,8 @@ def sync(self) -> None:
assert backend.callback is not None
backend.callback(None)
- assert calls == ["load:module args", "wait:mini_eq_sink:3000", "sync"]
- assert errors == ["filter-chain did not create mini_eq_sink"]
+ assert calls == ["load:module args", "wait:mini_eq_sink.dsp:3000", "sync"]
+ assert errors == ["filter-chain did not create mini_eq_sink.dsp"]
assert controller.engine_module is None
assert controller.filter_node_id is None
assert controller.running is False
@@ -1929,7 +2015,7 @@ def sync(self) -> None:
assert controller.running is False
-def test_restart_engine_pauses_stream_router_monitoring_during_restart() -> None:
+def test_restart_engine_preserves_player_routing_during_restart() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
controller.running = True
controller.routed = True
@@ -1957,16 +2043,10 @@ def start_monitoring(self, *, require_initial_route: bool = False) -> None:
routing.SystemWideEqController.restart_engine(controller)
- assert calls == [
- "stop-monitoring",
- "restore",
- "stop-engine:False",
- "start-engine",
- "start-monitoring:True",
- ]
+ assert calls == ["stop-engine:False", "start-engine"]
-def test_restart_engine_continues_when_routed_stream_restore_fails() -> None:
+def test_restart_engine_does_not_restore_routed_streams() -> None:
controller = routing.SystemWideEqController.__new__(routing.SystemWideEqController)
controller.running = True
controller.routed = True
@@ -1995,14 +2075,7 @@ def start_monitoring(self, *, require_initial_route: bool = False) -> None:
routing.SystemWideEqController.restart_engine(controller)
- assert calls == [
- "stop-monitoring",
- "restore",
- "warning:restore failed",
- "stop-engine:False",
- "start-engine",
- "start-monitoring:True",
- ]
+ assert calls == ["stop-engine:False", "start-engine"]
def test_emit_status_is_silent_during_shutdown(capsys: pytest.CaptureFixture[str]) -> None:
diff --git a/tests/test_mini_eq_window_analyzer.py b/tests/test_mini_eq_window_analyzer.py
index b7f7146..52f5803 100644
--- a/tests/test_mini_eq_window_analyzer.py
+++ b/tests/test_mini_eq_window_analyzer.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import threading
+
import pytest
from tests._mini_eq_imports import import_mini_eq_module
@@ -29,6 +31,9 @@ def __init__(self) -> None:
self.analyzer_enabled = True
self.analyzer_frozen = False
self.analyzer_levels = [0.0, 0.0]
+ self.analyzer_levels_lock = threading.Lock()
+ self.analyzer_pending_levels = None
+ self.analyzer_levels_idle_pending = False
self.analyzer_smoothing = 0.5
self.analyzer_last_frame_time = 0.0
self.control_analyzer_last_emit_time = 0.0
@@ -76,16 +81,24 @@ class FakeAnalyzerController:
def __init__(self, *, start_result: bool = True) -> None:
self.enabled_values: list[bool] = []
self.start_result = start_result
+ self.spectrum_enabled = True
def set_analyzer_enabled(self, enabled: bool) -> bool:
self.enabled_values.append(enabled)
return self.start_result
+ def set_analyzer_spectrum_enabled(self, enabled: bool) -> None:
+ self.spectrum_enabled = enabled
+
class TickAnalyzerArea:
def __init__(self) -> None:
self.callback = None
self.removed_ids: list[int] = []
+ self.mapped = True
+
+ def get_mapped(self) -> bool:
+ return self.mapped
def add_tick_callback(self, callback):
self.callback = callback
@@ -381,6 +394,20 @@ def test_analyzer_level_frames_emit_compact_control_signal() -> None:
assert window.application.state_count == 0
+def test_analyzer_dispatch_coalesces_frames_to_latest_levels(monkeypatch) -> None:
+ queued = []
+ monkeypatch.setattr(window_analyzer.GLib, "idle_add", lambda callback: queued.append(callback))
+ window = AnalyzerSignalWindow()
+ window.on_analyzer_levels([1.0, 0.2])
+ window.on_analyzer_levels([0.6, 0.8])
+ assert len(queued) == 1
+ assert queued.pop()() is False
+ assert window.analyzer_levels == pytest.approx([0.45, 0.6])
+ assert window.application.analyzer_count == 1
+ window.on_analyzer_levels([0.3, 0.4])
+ assert len(queued) == 1
+
+
def test_analyzer_redraw_skips_unmapped_area() -> None:
window = AnalyzerDrawWindow()
@@ -406,6 +433,58 @@ def test_analyzer_preview_uses_frame_clock_tick_callback() -> None:
assert window.analyzer_preview_uses_tick_callback is False
+def test_analyzer_hidden_clock_stops_but_capture_continues() -> None:
+ window = AnalyzerPreviewWindow()
+ window.start_analyzer_preview()
+ window.analyzer_area.mapped = False
+ window.on_analyzer_visibility_changed()
+ assert window.analyzer_area.removed_ids == [42]
+ assert window.analyzer_preview_source_id == 0
+ assert window.controller.enabled_values == [True]
+ window.analyzer_area.mapped = True
+ window.on_analyzer_visibility_changed()
+ assert window.analyzer_preview_source_id == 42
+ assert window.controller.enabled_values == [True]
+
+
+def test_analyzer_freeze_stops_clock_and_fft_then_resumes() -> None:
+ window = AnalyzerPreviewWindow()
+ window.start_analyzer_preview()
+ window.on_analyzer_freeze_changed(FakeSwitch(True), None)
+ assert window.analyzer_preview_source_id == 0
+ assert window.controller.spectrum_enabled is False
+ assert window.controller.enabled_values == [True]
+ window.on_analyzer_freeze_changed(FakeSwitch(False), None)
+ assert window.analyzer_preview_source_id == 42
+ assert window.controller.spectrum_enabled is True
+ assert window.controller.enabled_values == [True]
+
+
+def test_hidden_analyzer_stale_levels_decay_to_zero_for_shell(monkeypatch) -> None:
+ window = AnalyzerPreviewWindow()
+ window.ui_shutting_down = False
+ window.analyzer_last_frame_time = 0.0
+ window.analyzer_area.mapped = False
+ window.analyzer_levels = [0.8, 0.4]
+ queued = []
+ monkeypatch.setattr(
+ window_analyzer.GLib, "timeout_add", lambda interval, callback: queued.append((interval, callback)) or 9
+ )
+ window.schedule_background_analyzer_decay()
+ assert queued == [(100, window.on_background_analyzer_decay)]
+ window.schedule_background_analyzer_decay()
+ assert len(queued) == 1
+ now = iter(range(1_000_000, 5_000_000, 100_000))
+ monkeypatch.setattr(window_analyzer.GLib, "get_monotonic_time", lambda: next(now))
+ for _ in range(40):
+ if not window.on_background_analyzer_decay():
+ break
+ assert window.analyzer_levels == [0.0, 0.0]
+ assert window.analyzer_background_decay_source_id == 0
+ assert window.application.analyzer_count > 0
+ assert window.analyzer_preview_source_id == 0
+
+
def test_analyzer_preview_failure_refreshes_monitor_controls() -> None:
window = AnalyzerPreviewWindow(start_result=False)
diff --git a/tools/check_headless_pipewire_runtime.py b/tools/check_headless_pipewire_runtime.py
index 4dd409b..c18aecc 100755
--- a/tools/check_headless_pipewire_runtime.py
+++ b/tools/check_headless_pipewire_runtime.py
@@ -875,6 +875,48 @@ def run_controller_flow(
tmp_dir / "mini-eq-headless-baseline.raw",
timeout_seconds,
)
+ from mini_eq.core import FILTER_TYPES
+
+ print("## sparse native graph: enable and remove slot 31", flush=True)
+ controller.set_band_type(31, FILTER_TYPES["Bell"])
+ controller.set_band_frequency(31, 440.0)
+ controller.set_band_q(31, 4.0)
+ controller.set_band_gain(31, -6.0)
+ dispatch_until("sparse high-index topology ready", lambda: controller.running, timeout_seconds)
+ wait_for_stream_routed_and_processing(
+ smoke_id, virtual_sink_name, filter_output_name, timeout_seconds, "after adding slot 31"
+ )
+ sparse_rms = capture_sink_monitor_rms(controller.output_sink, tmp_dir / "sparse-slot.raw", timeout_seconds)
+ sparse_db = 20.0 * math.log10(max(sparse_rms, 1e-12) / max(baseline_rms, 1e-12))
+ if not -7.0 <= sparse_db <= -5.0:
+ raise RuntimeError(f"Sparse slot 31 response was {sparse_db:.2f} dB; expected -6 dB")
+ controller.set_band_type(31, FILTER_TYPES["Off"])
+ dispatch_until("sparse high-index topology removed", lambda: controller.running, timeout_seconds)
+ wait_for_stream_routed_and_processing(
+ smoke_id, virtual_sink_name, filter_output_name, timeout_seconds, "after removing slot 31"
+ )
+
+ print("## all-Off native graph retains preamp", flush=True)
+ payload = controller.build_preset_payload()
+ payload["preamp_db"] = -6.0
+ for band in payload["bands"]:
+ band["filter_type"] = FILTER_TYPES["Off"]
+ controller.apply_preset_payload(payload)
+ dispatch_until("all-Off topology ready", lambda: controller.running, timeout_seconds)
+ wait_for_stream_routed_and_processing(
+ smoke_id, virtual_sink_name, filter_output_name, timeout_seconds, "with all bands Off"
+ )
+ all_off_rms = capture_sink_monitor_rms(controller.output_sink, tmp_dir / "all-off.raw", timeout_seconds)
+ all_off_db = 20.0 * math.log10(max(all_off_rms, 1e-12) / max(baseline_rms, 1e-12))
+ if not -7.0 <= all_off_db <= -5.0:
+ raise RuntimeError(f"All-Off preamp response was {all_off_db:.2f} dB; expected -6 dB")
+ controller.reset_state()
+ dispatch_until("default topology restored", lambda: controller.running, timeout_seconds)
+ virtual_serial = wait_for_stream_routed_and_processing(
+ smoke_id, virtual_sink_name, filter_output_name, timeout_seconds, "after restoring default bands"
+ )
+ print(f"Sparse slot 31: {sparse_db:.2f} dB; all-Off preamp: {all_off_db:.2f} dB", flush=True)
+
# Exercise a frequency-selective live update, not only preamp gain:
# coefficients computed at the wrong rate still pass a preamp test.
controller.set_band_frequency(0, 440.0)
@@ -889,8 +931,6 @@ def run_controller_flow(
if not -13.0 <= bell_db <= -11.0:
raise RuntimeError(f"440 Hz bell response was {bell_db:.2f} dB; expected -12 dB")
print(f"Live 440 Hz bell response: {bell_db:.2f} dB", flush=True)
- from mini_eq.core import FILTER_TYPES
-
controller.set_band_type(0, FILTER_TYPES["Lo-pass"])
controller.set_band_frequency(0, 100.0)
controller.set_band_q(0, 0.707)
diff --git a/tools/check_mpris_preset_runtime.py b/tools/check_mpris_preset_runtime.py
new file mode 100644
index 0000000..01f307b
--- /dev/null
+++ b/tools/check_mpris_preset_runtime.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""Check preset rebuilds against WirePlumber's real MPRIS pause policy."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+try:
+ from tools import check_headless_pipewire_runtime as head
+ from tools import check_live_ui_runtime as live
+except ModuleNotFoundError: # direct script execution
+ import check_headless_pipewire_runtime as head
+ import check_live_ui_runtime as live
+
+MPRIS_XML = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+class MprisPlayer:
+ def __init__(self) -> None:
+ from gi.repository import Gio
+
+ self.pause_calls = 0
+ self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
+ self.registrations = [
+ self.bus.register_object("/org/mpris/MediaPlayer2", interface, self.method, self.property, None)
+ for interface in Gio.DBusNodeInfo.new_for_xml(MPRIS_XML).interfaces
+ ]
+ self.owner = Gio.bus_own_name_on_connection(
+ self.bus, "org.mpris.MediaPlayer2.minieqtest", Gio.BusNameOwnerFlags.NONE, None, None
+ )
+
+ def method(self, _connection, _sender, _path, _interface, name, _parameters, invocation):
+ from gi.repository import GLib
+
+ if name == "Pause":
+ self.pause_calls += 1
+ invocation.return_value(GLib.Variant("()", ()))
+
+ def property(self, _connection, _sender, _path, _interface, name):
+ from gi.repository import GLib
+
+ if name in ("Identity", "DesktopEntry"):
+ return GLib.Variant("s", "mini-eq-mpris-test")
+ if name == "PlaybackStatus":
+ return GLib.Variant("s", "Playing")
+ if name == "PauseCount":
+ return GLib.Variant("u", self.pause_calls)
+ if name in ("SupportedUriSchemes", "SupportedMimeTypes"):
+ return GLib.Variant("as", [])
+ return GLib.Variant("b", name in ("CanPause", "CanControl"))
+
+ def close(self) -> None:
+ from gi.repository import Gio
+
+ Gio.bus_unown_name(self.owner)
+ for registration in self.registrations:
+ self.bus.unregister_object(registration)
+
+
+class PlayerProbe:
+ """Query a player in a separate process, never Mini EQ's own PID tree."""
+
+ def __init__(self):
+ from gi.repository import Gio
+
+ self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
+
+ @property
+ def pause_calls(self) -> int:
+ from gi.repository import Gio, GLib
+
+ return self.bus.call_sync(
+ "org.mpris.MediaPlayer2.minieqtest",
+ "/org/mpris/MediaPlayer2",
+ "org.freedesktop.DBus.Properties",
+ "Get",
+ GLib.Variant("(ss)", ("org.mpris.MediaPlayer2.Player", "PauseCount")),
+ GLib.VariantType.new("(v)"),
+ Gio.DBusCallFlags.NONE,
+ 1000,
+ None,
+ ).unpack()[0]
+
+
+def run_player_helper(audio: str) -> int:
+ from gi.repository import GLib
+
+ player = MprisPlayer()
+ playback = subprocess.Popen(["paplay", "--client-name=mini-eq-mpris-test", audio])
+ loop = GLib.MainLoop()
+ GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, lambda: loop.quit() or False)
+ try:
+ loop.run()
+ finally:
+ live.terminate_process(playback, "Pulse playback")
+ player.close()
+ return 0
+
+
+def observe(seconds: float) -> None:
+ from gi.repository import GLib
+
+ deadline = time.monotonic() + seconds
+ while time.monotonic() < deadline:
+ head.drain_main_context(GLib.MainContext.default())
+ time.sleep(0.02)
+
+
+def run_helper() -> int:
+ for tool in ("pipewire", "pipewire-pulse", "wireplumber", "paplay", "pw-dump", "wpctl", "pw-metadata"):
+ live.require_tool(tool)
+ head.ensure_source_path()
+ from mini_eq.core import FILTER_TYPES, inactive_eq_bands
+ from mini_eq.routing import SystemWideEqController
+
+ pw = wp = pulse = playback = controller = player = None
+ with tempfile.TemporaryDirectory(prefix="mini-eq-mpris-runtime-") as directory:
+ root = Path(directory)
+ for name in ("runtime", "config", "data", "cache"):
+ (root / name).mkdir(mode=0o700)
+ for key, name in (
+ ("XDG_RUNTIME_DIR", "runtime"),
+ ("XDG_CONFIG_HOME", "config"),
+ ("XDG_DATA_HOME", "data"),
+ ("XDG_CACHE_HOME", "cache"),
+ ):
+ os.environ[key] = str(root / name)
+ os.environ["PULSE_SERVER"] = "unix:" + str(root / "runtime/pulse/native")
+ live.write_pipewire_config(root / "config")
+ # The private graph must not enumerate or compete with host devices.
+ wp_config = root / "config/wireplumber/wireplumber.conf.d"
+ wp_config.mkdir(parents=True)
+ (wp_config / "10-private-audio.conf").write_text(
+ "wireplumber.profiles = { main = { "
+ "monitor.alsa = disabled monitor.bluez = disabled "
+ "monitor.v4l2 = disabled monitor.libcamera = disabled } }\n",
+ encoding="utf-8",
+ )
+ with (root / "pulse.log").open("w") as pulse_log, (root / "player.log").open("w") as player_log:
+ try:
+ pw, wp = head.start_pipewire_session(root, 15)
+ pulse = subprocess.Popen(["pipewire-pulse"], stdout=pulse_log, stderr=subprocess.STDOUT)
+ live.wait_for("private Pulse socket", lambda: (root / "runtime/pulse/native").exists(), 15)
+ subprocess.run(["wpctl", "settings", "linking.pause-playback", "true"], check=True)
+ audio = live.create_sine_wav(root / "signal.wav", 90)
+ playback = subprocess.Popen(
+ [sys.executable, str(Path(__file__).resolve()), "--player-helper", str(audio)],
+ stdout=player_log,
+ stderr=subprocess.STDOUT,
+ )
+ player = PlayerProbe()
+ head.dispatch_until("MPRIS player ready", lambda: player.pause_calls == 0, 15)
+ node = live.wait_for(
+ "Pulse player node",
+ lambda: next(
+ (
+ n
+ for n in live.node_items()
+ if n.get("info", {}).get("props", {}).get("application.name") == "mini-eq-mpris-test"
+ ),
+ None,
+ ),
+ 15,
+ )
+ stream_id = live.node_id(node)
+ controller = SystemWideEqController(live.PRIMARY_SINK_NAME)
+ controller.bands = inactive_eq_bands()
+ head.wait_for_controller_ready(controller, 15)
+ head.wait_for_smoke_routed(stream_id, controller.virtual_sink_name, 15)
+ observe(2)
+ serial = live.object_serial(live.node_by_name(controller.virtual_sink_name))
+ for name in ("Bell", "Lo-pass", "Off"):
+ controller.set_band_type(0, FILTER_TYPES[name])
+ head.dispatch_until("DSP replacement ready", lambda: controller.running, 15)
+ head.wait_for_processing_path_active(
+ controller.virtual_sink_name, controller.filter_output_name, 15
+ )
+ observe(2)
+ if live.object_serial(live.node_by_name(controller.virtual_sink_name)) != serial:
+ raise RuntimeError("Preset change replaced the player-facing device")
+ if player.pause_calls:
+ raise RuntimeError(f"WirePlumber paused the player after {name}")
+ if playback.poll() is not None:
+ raise RuntimeError("Pulse playback disconnected")
+ print("Preset/type replacements: stable sink, active audio path, zero MPRIS Pause calls", flush=True)
+ controller.shutdown()
+ controller = None
+ observe(2)
+ if player.pause_calls:
+ raise RuntimeError("WirePlumber paused the player on normal shutdown")
+ print("Normal shutdown: zero MPRIS Pause calls", flush=True)
+ # A positive control prevents a broken/unmatched MPRIS fixture
+ # from making the zero-pause assertions pass vacuously.
+ controller = SystemWideEqController(live.PRIMARY_SINK_NAME)
+ head.wait_for_controller_ready(controller, 15)
+ head.wait_for_smoke_routed(stream_id, controller.virtual_sink_name, 15)
+ observe(2)
+ controller.stream_router.stop_monitoring()
+ controller.output_backend.unload_filter_chain_module(controller.input_module)
+ controller.input_module = None
+ head.dispatch_until("device-removal MPRIS positive control", lambda: player.pause_calls > 0, 15)
+ print("Positive control: device removal invokes MPRIS Pause", flush=True)
+ finally:
+ try:
+ if controller is not None:
+ controller.shutdown()
+ finally:
+ for process, label in (
+ (playback, "Pulse player"),
+ (pulse, "Pulse server"),
+ (wp, "WirePlumber"),
+ (pw, "PipeWire"),
+ ):
+ live.terminate_process(process, label)
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--helper", action="store_true", help=argparse.SUPPRESS)
+ parser.add_argument("--player-helper", help=argparse.SUPPRESS)
+ args = parser.parse_args()
+ if args.player_helper:
+ return run_player_helper(args.player_helper)
+ if args.helper:
+ return run_helper()
+ return subprocess.run(
+ ["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--helper"]
+ ).returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())