Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 12 additions & 2 deletions data/io.github.bhack.mini-eq.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,28 @@
</description>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.8/docs/screenshots/mini-eq.png</image>
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.9/docs/screenshots/mini-eq.png</image>
<caption>Adjust sound output with equalizer controls</caption>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.8/docs/screenshots/mini-eq-dark.png</image>
<image>https://raw.githubusercontent.com/bhack/mini-eq/v0.8.9/docs/screenshots/mini-eq-dark.png</image>
<caption>Use the equalizer with dark style</caption>
</screenshot>
</screenshots>
<url type="homepage">https://github.com/bhack/mini-eq</url>
<url type="bugtracker">https://github.com/bhack/mini-eq/issues</url>
<url type="vcs-browser">https://github.com/bhack/mini-eq</url>
<releases>
<release version="0.8.9" date="2026-10-13">
<description>
<ul>
<li>Keep the audio device stable when changing presets or filter types, avoiding media-player pauses caused by device removal.</li>
<li>Avoid processing unused Off bands while keeping all 32 band slots available.</li>
<li>Stop analyzer animation and spectrum calculations while frozen, and stop animation while the window is hidden.</li>
<li>Use audio-driven analyzer wakeups and coalesced display updates while preserving loudness measurement and background Shell meters.</li>
</ul>
</description>
</release>
<release version="0.8.8" date="2026-09-17">
<description>
<ul>
Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
13 changes: 13 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions io.github.bhack.mini-eq.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 21 additions & 2 deletions src/mini_eq/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -476,6 +481,7 @@ def start(self) -> bool:
return True

self.audio_blocks.clear()
self.audio_blocks_ready.clear()
self.stop_event.clear()

try:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion src/mini_eq/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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",
Expand Down
55 changes: 48 additions & 7 deletions src/mini_eq/filter_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand All @@ -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


Expand All @@ -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))
Expand All @@ -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(
[
Expand All @@ -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}"

Expand Down Expand Up @@ -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)}
Expand All @@ -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
Expand Down
Loading
Loading