mini-eq 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mini_eq/__init__.py +5 -0
- mini_eq/__main__.py +25 -0
- mini_eq/analyzer.py +660 -0
- mini_eq/app.py +195 -0
- mini_eq/assets/icons/hicolor/128x128/apps/io.github.bhack.mini-eq.png +0 -0
- mini_eq/assets/icons/hicolor/256x256/apps/io.github.bhack.mini-eq.png +0 -0
- mini_eq/assets/icons/hicolor/512x512/apps/io.github.bhack.mini-eq.png +0 -0
- mini_eq/assets/icons/hicolor/64x64/apps/io.github.bhack.mini-eq.png +0 -0
- mini_eq/assets/source/mini-eq-generated-icon-original-rgb.png +0 -0
- mini_eq/assets/source/mini-eq-generated-icon.png +0 -0
- mini_eq/band_fader.py +849 -0
- mini_eq/cli.py +45 -0
- mini_eq/core.py +758 -0
- mini_eq/deps.py +341 -0
- mini_eq/desktop_integration.py +112 -0
- mini_eq/filter_chain.py +243 -0
- mini_eq/filter_glyph.py +106 -0
- mini_eq/glib_utils.py +9 -0
- mini_eq/gtk_utils.py +16 -0
- mini_eq/instance.py +263 -0
- mini_eq/routing.py +683 -0
- mini_eq/screenshot.py +41 -0
- mini_eq/style.css +320 -0
- mini_eq/window.py +639 -0
- mini_eq/window_analyzer.py +269 -0
- mini_eq/window_graph.py +895 -0
- mini_eq/window_layout.py +802 -0
- mini_eq/window_presets.py +283 -0
- mini_eq/wireplumber_backend.py +678 -0
- mini_eq/wireplumber_stream_router.py +180 -0
- mini_eq-0.1.1.dist-info/METADATA +181 -0
- mini_eq-0.1.1.dist-info/RECORD +36 -0
- mini_eq-0.1.1.dist-info/WHEEL +5 -0
- mini_eq-0.1.1.dist-info/entry_points.txt +2 -0
- mini_eq-0.1.1.dist-info/licenses/LICENSE +674 -0
- mini_eq-0.1.1.dist-info/top_level.txt +1 -0
mini_eq/__init__.py
ADDED
mini_eq/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def console_main(argv: list[str] | None = None) -> int:
|
|
7
|
+
if argv is None:
|
|
8
|
+
argv = sys.argv[1:]
|
|
9
|
+
|
|
10
|
+
from .cli import parse_args
|
|
11
|
+
|
|
12
|
+
args = parse_args(argv)
|
|
13
|
+
|
|
14
|
+
if args.check_deps:
|
|
15
|
+
from .deps import main as check_deps_main
|
|
16
|
+
|
|
17
|
+
return check_deps_main()
|
|
18
|
+
|
|
19
|
+
from .app import run_from_args
|
|
20
|
+
|
|
21
|
+
return run_from_args(args)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
raise SystemExit(console_main())
|
mini_eq/analyzer.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from array import array
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
from .core import GRAPH_FREQ_MAX, GRAPH_FREQ_MIN, SAMPLE_RATE, clamp
|
|
13
|
+
|
|
14
|
+
ANALYZER_BAND_FREQUENCIES = (
|
|
15
|
+
25.0,
|
|
16
|
+
31.5,
|
|
17
|
+
40.0,
|
|
18
|
+
50.0,
|
|
19
|
+
63.0,
|
|
20
|
+
80.0,
|
|
21
|
+
100.0,
|
|
22
|
+
125.0,
|
|
23
|
+
160.0,
|
|
24
|
+
200.0,
|
|
25
|
+
250.0,
|
|
26
|
+
315.0,
|
|
27
|
+
400.0,
|
|
28
|
+
500.0,
|
|
29
|
+
630.0,
|
|
30
|
+
800.0,
|
|
31
|
+
1000.0,
|
|
32
|
+
1250.0,
|
|
33
|
+
1600.0,
|
|
34
|
+
2000.0,
|
|
35
|
+
2500.0,
|
|
36
|
+
3150.0,
|
|
37
|
+
4000.0,
|
|
38
|
+
5000.0,
|
|
39
|
+
6300.0,
|
|
40
|
+
8000.0,
|
|
41
|
+
10000.0,
|
|
42
|
+
12500.0,
|
|
43
|
+
16000.0,
|
|
44
|
+
20000.0,
|
|
45
|
+
)
|
|
46
|
+
ANALYZER_BIN_COUNT = len(ANALYZER_BAND_FREQUENCIES)
|
|
47
|
+
ANALYZER_DB_FLOOR = -100.0
|
|
48
|
+
ANALYZER_INTERVAL_MS = 33
|
|
49
|
+
ANALYZER_FFT_WINDOW_SECONDS = 0.085
|
|
50
|
+
ANALYZER_FFT_MIN_SIZE = 4096
|
|
51
|
+
ANALYZER_FFT_MAX_SIZE = 16384
|
|
52
|
+
ANALYZER_SAMPLE_WIDTH_BYTES = 4
|
|
53
|
+
ANALYZER_QUEUE_WAIT_SECONDS = 0.005
|
|
54
|
+
ANALYZER_READER_JOIN_TIMEOUT_SECONDS = 0.2
|
|
55
|
+
ANALYZER_DISPLAY_GAIN_MIN = -12.0
|
|
56
|
+
ANALYZER_DISPLAY_GAIN_MAX = 32.0
|
|
57
|
+
ANALYZER_DISPLAY_GAIN_DEFAULT = 0.0
|
|
58
|
+
JACK_CLIENT_NAME = "mini-eq-analyzer"
|
|
59
|
+
JACK_LEFT_INPUT_PORT = "input_FL"
|
|
60
|
+
JACK_RIGHT_INPUT_PORT = "input_FR"
|
|
61
|
+
JACK_CAPTURE_QUEUE_BLOCKS = 128
|
|
62
|
+
JACK_PIPEWIRE_PROPS = (
|
|
63
|
+
"node.autoconnect = false node.dont-move = true stream.monitor = true media.category = Monitor media.role = DSP"
|
|
64
|
+
)
|
|
65
|
+
ANALYZER_RESPONSE_MIN = 0.02
|
|
66
|
+
ANALYZER_RESPONSE_MAX = 15.0
|
|
67
|
+
ANALYZER_RESPONSE_DEFAULT = 2.0
|
|
68
|
+
ANALYZER_POWER_FLOOR = 10.0 ** (ANALYZER_DB_FLOOR / 10.0)
|
|
69
|
+
_numpy_module = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def require_numpy():
|
|
73
|
+
global _numpy_module
|
|
74
|
+
|
|
75
|
+
if _numpy_module is not None:
|
|
76
|
+
return _numpy_module
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
import numpy
|
|
80
|
+
except Exception as exc: # pragma: no cover - depends on installed Python deps
|
|
81
|
+
raise RuntimeError("Mini EQ analyzer requires NumPy for FFT analysis") from exc
|
|
82
|
+
|
|
83
|
+
_numpy_module = numpy
|
|
84
|
+
return numpy
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_spectrum_db(db_value: float) -> float:
|
|
88
|
+
return clamp((db_value - ANALYZER_DB_FLOOR) / abs(ANALYZER_DB_FLOOR), 0.0, 1.0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def spectrum_level_to_db(level: float) -> float:
|
|
92
|
+
return ANALYZER_DB_FLOOR + (clamp(float(level), 0.0, 1.0) * abs(ANALYZER_DB_FLOOR))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def analyzer_db_to_display_norm(db_value: float, display_gain_db: float = 0.0) -> float:
|
|
96
|
+
display_db = float(db_value) + float(display_gain_db)
|
|
97
|
+
|
|
98
|
+
# Match the useful x42-style meter shape: hide very low noise, expand the musical range.
|
|
99
|
+
if display_db < -70.0:
|
|
100
|
+
deflection = 0.0
|
|
101
|
+
elif display_db < -60.0:
|
|
102
|
+
deflection = (display_db + 70.0) * 0.25
|
|
103
|
+
elif display_db < -50.0:
|
|
104
|
+
deflection = ((display_db + 60.0) * 0.5) + 2.5
|
|
105
|
+
elif display_db < -40.0:
|
|
106
|
+
deflection = ((display_db + 50.0) * 0.75) + 7.5
|
|
107
|
+
elif display_db < -30.0:
|
|
108
|
+
deflection = ((display_db + 40.0) * 1.5) + 15.0
|
|
109
|
+
elif display_db < -20.0:
|
|
110
|
+
deflection = ((display_db + 30.0) * 2.0) + 30.0
|
|
111
|
+
elif display_db < 6.0:
|
|
112
|
+
deflection = ((display_db + 20.0) * 2.5) + 50.0
|
|
113
|
+
else:
|
|
114
|
+
deflection = 115.0
|
|
115
|
+
|
|
116
|
+
return clamp(deflection / 115.0, 0.0, 1.0)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def analyzer_level_to_display_norm(level: float, display_gain_db: float = 0.0) -> float:
|
|
120
|
+
return analyzer_db_to_display_norm(spectrum_level_to_db(level), display_gain_db)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def spectrum_db_values_to_levels(db_values: tuple[float, ...] | list[float]) -> list[float]:
|
|
124
|
+
return [normalize_spectrum_db(float(value)) for value in db_values]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def analyzer_frame_count(sample_rate: float = SAMPLE_RATE) -> int:
|
|
128
|
+
return max(1, int(max(1.0, float(sample_rate)) * ANALYZER_INTERVAL_MS / 1000.0))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def next_power_of_two(value: int) -> int:
|
|
132
|
+
return 1 << max(0, int(value) - 1).bit_length()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def analyzer_fft_size(sample_rate: float = SAMPLE_RATE) -> int:
|
|
136
|
+
target = int(max(1.0, float(sample_rate)) * ANALYZER_FFT_WINDOW_SECONDS)
|
|
137
|
+
return int(clamp(next_power_of_two(target), ANALYZER_FFT_MIN_SIZE, ANALYZER_FFT_MAX_SIZE))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def analyzer_smoothing_alpha(
|
|
141
|
+
response_speed: float,
|
|
142
|
+
frame_count: int,
|
|
143
|
+
sample_rate: float = SAMPLE_RATE,
|
|
144
|
+
) -> float:
|
|
145
|
+
speed = clamp(float(response_speed), ANALYZER_RESPONSE_MIN, ANALYZER_RESPONSE_MAX)
|
|
146
|
+
return 1.0 - math.exp(-2.0 * math.pi * speed * max(1, int(frame_count)) / max(1.0, float(sample_rate)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@lru_cache(maxsize=32)
|
|
150
|
+
def analyzer_bin_center_frequencies(
|
|
151
|
+
level_count: int = ANALYZER_BIN_COUNT,
|
|
152
|
+
freq_min: float = GRAPH_FREQ_MIN,
|
|
153
|
+
freq_max: float = GRAPH_FREQ_MAX,
|
|
154
|
+
) -> tuple[float, ...]:
|
|
155
|
+
if level_count == ANALYZER_BIN_COUNT and freq_min == GRAPH_FREQ_MIN and freq_max == GRAPH_FREQ_MAX:
|
|
156
|
+
return ANALYZER_BAND_FREQUENCIES
|
|
157
|
+
|
|
158
|
+
log_min = math.log(freq_min)
|
|
159
|
+
log_span = math.log(freq_max / freq_min)
|
|
160
|
+
return tuple(math.exp(log_min + (log_span * (index + 0.5) / level_count)) for index in range(level_count))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@lru_cache(maxsize=32)
|
|
164
|
+
def analyzer_band_edges(
|
|
165
|
+
center_frequencies: tuple[float, ...] = ANALYZER_BAND_FREQUENCIES,
|
|
166
|
+
) -> tuple[float, ...]:
|
|
167
|
+
if not center_frequencies:
|
|
168
|
+
return ()
|
|
169
|
+
if len(center_frequencies) == 1:
|
|
170
|
+
center = center_frequencies[0]
|
|
171
|
+
return (center / math.sqrt(2.0), center * math.sqrt(2.0))
|
|
172
|
+
|
|
173
|
+
middle_edges = [
|
|
174
|
+
math.sqrt(left * right) for left, right in zip(center_frequencies, center_frequencies[1:], strict=False)
|
|
175
|
+
]
|
|
176
|
+
first_edge = center_frequencies[0] * center_frequencies[0] / middle_edges[0]
|
|
177
|
+
last_edge = center_frequencies[-1] * center_frequencies[-1] / middle_edges[-1]
|
|
178
|
+
return (first_edge, *middle_edges, last_edge)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def pcm_f32le_bytes_to_samples(payload: bytes) -> array:
|
|
182
|
+
usable_size = len(payload) - (len(payload) % ANALYZER_SAMPLE_WIDTH_BYTES)
|
|
183
|
+
samples = array("f")
|
|
184
|
+
samples.frombytes(payload[:usable_size])
|
|
185
|
+
if sys.byteorder != "little":
|
|
186
|
+
samples.byteswap()
|
|
187
|
+
return samples
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def stereo_f32le_bytes_to_mono_samples(left_payload: bytes, right_payload: bytes | None = None) -> array:
|
|
191
|
+
left_samples = pcm_f32le_bytes_to_samples(left_payload)
|
|
192
|
+
if right_payload is None:
|
|
193
|
+
return left_samples
|
|
194
|
+
|
|
195
|
+
usable_size = min(len(left_payload), len(right_payload))
|
|
196
|
+
usable_size -= usable_size % ANALYZER_SAMPLE_WIDTH_BYTES
|
|
197
|
+
sample_count = usable_size // ANALYZER_SAMPLE_WIDTH_BYTES
|
|
198
|
+
if sample_count == 0:
|
|
199
|
+
return array("f")
|
|
200
|
+
|
|
201
|
+
np = require_numpy()
|
|
202
|
+
left_values = np.frombuffer(left_payload[:usable_size], dtype="<f4")
|
|
203
|
+
right_values = np.frombuffer(right_payload[:usable_size], dtype="<f4")
|
|
204
|
+
mono_values = ((left_values + right_values) * np.float32(0.5)).astype(np.float32, copy=False)
|
|
205
|
+
samples = array("f")
|
|
206
|
+
samples.frombytes(mono_values.tobytes())
|
|
207
|
+
return samples
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def samples_to_numpy_window(samples: array, fft_size: int):
|
|
211
|
+
np = require_numpy()
|
|
212
|
+
size = max(2, int(fft_size))
|
|
213
|
+
try:
|
|
214
|
+
sample_values = np.frombuffer(samples, dtype=np.float32)
|
|
215
|
+
except TypeError:
|
|
216
|
+
sample_values = np.asarray(samples, dtype=np.float32)
|
|
217
|
+
|
|
218
|
+
if len(sample_values) >= size:
|
|
219
|
+
return sample_values[-size:]
|
|
220
|
+
|
|
221
|
+
fft_samples = np.zeros(size, dtype=np.float32)
|
|
222
|
+
if len(sample_values) > 0:
|
|
223
|
+
fft_samples[-len(sample_values) :] = sample_values
|
|
224
|
+
return fft_samples
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@lru_cache(maxsize=16)
|
|
228
|
+
def analyzer_fft_window(fft_size: int):
|
|
229
|
+
np = require_numpy()
|
|
230
|
+
return np.hanning(max(2, int(fft_size))).astype(np.float32)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@lru_cache(maxsize=64)
|
|
234
|
+
def analyzer_fft_band_bin_ranges(
|
|
235
|
+
fft_size: int,
|
|
236
|
+
sample_rate: float,
|
|
237
|
+
center_frequencies: tuple[float, ...] = ANALYZER_BAND_FREQUENCIES,
|
|
238
|
+
) -> tuple[tuple[int, int], ...]:
|
|
239
|
+
np = require_numpy()
|
|
240
|
+
size = max(2, int(fft_size))
|
|
241
|
+
frequencies = np.fft.rfftfreq(size, 1.0 / max(1.0, float(sample_rate)))
|
|
242
|
+
edges = analyzer_band_edges(center_frequencies)
|
|
243
|
+
ranges: list[tuple[int, int]] = []
|
|
244
|
+
|
|
245
|
+
for index, center in enumerate(center_frequencies):
|
|
246
|
+
left = edges[index]
|
|
247
|
+
right = edges[index + 1]
|
|
248
|
+
start = int(np.searchsorted(frequencies, left, side="left"))
|
|
249
|
+
stop_side = "right" if index == len(center_frequencies) - 1 else "left"
|
|
250
|
+
stop = int(np.searchsorted(frequencies, right, side=stop_side))
|
|
251
|
+
start = max(1, start)
|
|
252
|
+
|
|
253
|
+
if stop <= start:
|
|
254
|
+
nearest = int(np.abs(frequencies - center).argmin())
|
|
255
|
+
start = max(1, nearest)
|
|
256
|
+
stop = min(len(frequencies), start + 1)
|
|
257
|
+
|
|
258
|
+
ranges.append((start, stop))
|
|
259
|
+
|
|
260
|
+
return tuple(ranges)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def samples_to_log_band_powers(
|
|
264
|
+
samples: array,
|
|
265
|
+
*,
|
|
266
|
+
sample_rate: float = SAMPLE_RATE,
|
|
267
|
+
center_frequencies: tuple[float, ...] = ANALYZER_BAND_FREQUENCIES,
|
|
268
|
+
fft_size: int | None = None,
|
|
269
|
+
) -> tuple[float, ...]:
|
|
270
|
+
if not samples:
|
|
271
|
+
return ()
|
|
272
|
+
|
|
273
|
+
np = require_numpy()
|
|
274
|
+
size = fft_size or len(samples)
|
|
275
|
+
fft_samples = samples_to_numpy_window(samples, size)
|
|
276
|
+
window = analyzer_fft_window(size)
|
|
277
|
+
windowed_samples = fft_samples * window
|
|
278
|
+
spectrum = np.fft.rfft(windowed_samples)
|
|
279
|
+
amplitude_normalizer = max(float(window.sum()) / 2.0, 1e-12)
|
|
280
|
+
bin_powers = (np.abs(spectrum) / amplitude_normalizer) ** 2
|
|
281
|
+
if len(bin_powers) > 0:
|
|
282
|
+
bin_powers[0] = 0.0
|
|
283
|
+
|
|
284
|
+
return tuple(
|
|
285
|
+
float(bin_powers[start:stop].sum())
|
|
286
|
+
for start, stop in analyzer_fft_band_bin_ranges(size, sample_rate, center_frequencies)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def smooth_power_values(
|
|
291
|
+
previous: tuple[float, ...],
|
|
292
|
+
current: tuple[float, ...],
|
|
293
|
+
alpha: float,
|
|
294
|
+
) -> tuple[float, ...]:
|
|
295
|
+
if not current:
|
|
296
|
+
return previous
|
|
297
|
+
if len(previous) != len(current):
|
|
298
|
+
previous = (0.0,) * len(current)
|
|
299
|
+
|
|
300
|
+
mix = clamp(float(alpha), 0.0, 1.0)
|
|
301
|
+
return tuple(old + (mix * (new - old)) for old, new in zip(previous, current, strict=True))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def power_values_to_db_values(power_values: tuple[float, ...]) -> tuple[float, ...]:
|
|
305
|
+
return tuple(
|
|
306
|
+
max(10.0 * math.log10(max(float(power), ANALYZER_POWER_FLOOR)), ANALYZER_DB_FLOOR) for power in power_values
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def samples_to_log_band_db_values(
|
|
311
|
+
samples: array,
|
|
312
|
+
*,
|
|
313
|
+
sample_rate: float = SAMPLE_RATE,
|
|
314
|
+
center_frequencies: tuple[float, ...] = ANALYZER_BAND_FREQUENCIES,
|
|
315
|
+
fft_size: int | None = None,
|
|
316
|
+
) -> tuple[float, ...]:
|
|
317
|
+
return power_values_to_db_values(
|
|
318
|
+
samples_to_log_band_powers(
|
|
319
|
+
samples,
|
|
320
|
+
sample_rate=sample_rate,
|
|
321
|
+
center_frequencies=center_frequencies,
|
|
322
|
+
fft_size=fft_size,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def samples_to_levels(samples: array, sample_rate: float = SAMPLE_RATE, fft_size: int | None = None) -> list[float]:
|
|
328
|
+
return spectrum_db_values_to_levels(
|
|
329
|
+
samples_to_log_band_db_values(samples, sample_rate=sample_rate, fft_size=fft_size)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def jack_port_name(port) -> str:
|
|
334
|
+
return str(getattr(port, "name", port))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def jack_port_short_name(port) -> str:
|
|
338
|
+
return str(getattr(port, "shortname", jack_port_name(port).rsplit(":", 1)[-1]))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def jack_port_client_name(port) -> str:
|
|
342
|
+
return jack_port_name(port).rsplit(":", 1)[0]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def jack_sink_name_candidates(sink_name: str, sink_description: str | None = None) -> tuple[str, ...]:
|
|
346
|
+
candidates: list[str] = []
|
|
347
|
+
|
|
348
|
+
for candidate in (sink_description, sink_name):
|
|
349
|
+
if candidate and candidate not in candidates:
|
|
350
|
+
candidates.append(candidate)
|
|
351
|
+
|
|
352
|
+
return tuple(candidates)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def jack_port_client_matches_sink(port, sink_candidates: set[str]) -> bool:
|
|
356
|
+
client_name = jack_port_client_name(port)
|
|
357
|
+
return any(client_name == candidate or client_name.startswith(f"{candidate}-") for candidate in sink_candidates)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def jack_pipewire_props(existing_props: str | None = None) -> str:
|
|
361
|
+
if existing_props and not existing_props.lstrip().startswith("{"):
|
|
362
|
+
return f"{existing_props} {JACK_PIPEWIRE_PROPS}"
|
|
363
|
+
|
|
364
|
+
return JACK_PIPEWIRE_PROPS
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def jack_audio_output_ports_for_sink(
|
|
368
|
+
ports: list,
|
|
369
|
+
sink_name: str,
|
|
370
|
+
sink_description: str | None = None,
|
|
371
|
+
) -> list:
|
|
372
|
+
candidates = set(jack_sink_name_candidates(sink_name, sink_description))
|
|
373
|
+
return [port for port in ports if jack_port_client_matches_sink(port, candidates)]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def select_jack_stereo_output_ports(ports: list) -> tuple[object | None, object | None]:
|
|
377
|
+
left_suffixes = ("monitor_FL", "monitor_AUX0")
|
|
378
|
+
right_suffixes = ("monitor_FR", "monitor_AUX1")
|
|
379
|
+
mono_suffixes = ("monitor_MONO",)
|
|
380
|
+
|
|
381
|
+
def find_by_suffix(suffixes: tuple[str, ...]):
|
|
382
|
+
for suffix in suffixes:
|
|
383
|
+
for port in ports:
|
|
384
|
+
if jack_port_short_name(port) == suffix or jack_port_short_name(port).endswith(f"_{suffix}"):
|
|
385
|
+
return port
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
left = find_by_suffix(left_suffixes)
|
|
389
|
+
right = find_by_suffix(right_suffixes)
|
|
390
|
+
mono = find_by_suffix(mono_suffixes)
|
|
391
|
+
|
|
392
|
+
if left is None and right is None and mono is not None:
|
|
393
|
+
return mono, mono
|
|
394
|
+
|
|
395
|
+
if left is None and right is not None:
|
|
396
|
+
left = right
|
|
397
|
+
if right is None and left is not None:
|
|
398
|
+
right = left
|
|
399
|
+
|
|
400
|
+
return left, right
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def disconnect_jack_input_port_connections(client, input_ports: tuple[object | None, ...]) -> None:
|
|
404
|
+
for input_port in input_ports:
|
|
405
|
+
if input_port is None:
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
connections = client.get_all_connections(input_port)
|
|
410
|
+
except Exception:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
for source_port in connections:
|
|
414
|
+
try:
|
|
415
|
+
client.disconnect(source_port, input_port)
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class OutputSpectrumAnalyzer:
|
|
421
|
+
def __init__(
|
|
422
|
+
self,
|
|
423
|
+
output_sink_name: str,
|
|
424
|
+
levels_callback: Callable[[list[float]], None] | None,
|
|
425
|
+
status_callback: Callable[[str], None],
|
|
426
|
+
output_sink_description: str | None = None,
|
|
427
|
+
) -> None:
|
|
428
|
+
self.output_sink_name = output_sink_name
|
|
429
|
+
self.output_sink_description = output_sink_description
|
|
430
|
+
self.levels_callback = levels_callback
|
|
431
|
+
self.status_callback = status_callback
|
|
432
|
+
self.enabled = False
|
|
433
|
+
self.client = None
|
|
434
|
+
self.client_active = False
|
|
435
|
+
self.left_input_port = None
|
|
436
|
+
self.right_input_port = None
|
|
437
|
+
self.reader_thread: threading.Thread | None = None
|
|
438
|
+
self.stop_event = threading.Event()
|
|
439
|
+
self.stop_event.set()
|
|
440
|
+
self.audio_blocks = deque(maxlen=JACK_CAPTURE_QUEUE_BLOCKS)
|
|
441
|
+
self.sample_rate = SAMPLE_RATE
|
|
442
|
+
self.response_speed = ANALYZER_RESPONSE_DEFAULT
|
|
443
|
+
|
|
444
|
+
def set_levels_callback(self, callback: Callable[[list[float]], None] | None) -> None:
|
|
445
|
+
self.levels_callback = callback
|
|
446
|
+
|
|
447
|
+
def set_response_speed(self, speed: float) -> None:
|
|
448
|
+
self.response_speed = clamp(float(speed), ANALYZER_RESPONSE_MIN, ANALYZER_RESPONSE_MAX)
|
|
449
|
+
|
|
450
|
+
def set_output_sink_name(self, sink_name: str, sink_description: str | None = None) -> None:
|
|
451
|
+
if sink_name == self.output_sink_name and sink_description == self.output_sink_description:
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
self.output_sink_name = sink_name
|
|
455
|
+
self.output_sink_description = sink_description
|
|
456
|
+
if not self.enabled:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
if self.client is None:
|
|
460
|
+
self.restart()
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
disconnect_jack_input_port_connections(self.client, (self.left_input_port, self.right_input_port))
|
|
465
|
+
self.connect_jack_monitor_ports(self.client)
|
|
466
|
+
except Exception as exc:
|
|
467
|
+
self.status_callback(f"analyzer output reconnect failed: {exc}")
|
|
468
|
+
|
|
469
|
+
def set_enabled(self, enabled: bool) -> bool:
|
|
470
|
+
self.enabled = bool(enabled)
|
|
471
|
+
|
|
472
|
+
if not self.enabled:
|
|
473
|
+
self.stop()
|
|
474
|
+
return True
|
|
475
|
+
|
|
476
|
+
return self.start()
|
|
477
|
+
|
|
478
|
+
def start(self) -> bool:
|
|
479
|
+
if self.reader_thread is not None:
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
self.audio_blocks.clear()
|
|
483
|
+
self.stop_event.clear()
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
if self.client is None:
|
|
487
|
+
self.client = self.open_jack_client()
|
|
488
|
+
self.activate_jack_client(self.client)
|
|
489
|
+
except Exception as exc:
|
|
490
|
+
self.stop_event.set()
|
|
491
|
+
self.status_callback(f"Analyzer Unavailable: {exc}")
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
self.reader_thread = threading.Thread(target=self.read_jack_levels, name="mini-eq-analyzer", daemon=True)
|
|
495
|
+
self.reader_thread.start()
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
def prepare(self) -> bool:
|
|
499
|
+
if self.client is not None:
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
self.client = self.open_jack_client()
|
|
504
|
+
except Exception:
|
|
505
|
+
return False
|
|
506
|
+
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
def stop(self, *, close_client: bool = False) -> None:
|
|
510
|
+
client = self.client
|
|
511
|
+
reader_thread = self.reader_thread
|
|
512
|
+
|
|
513
|
+
self.reader_thread = None
|
|
514
|
+
self.stop_event.set()
|
|
515
|
+
self.audio_blocks.clear()
|
|
516
|
+
|
|
517
|
+
if client is not None and self.client_active and not close_client:
|
|
518
|
+
self.deactivate_jack_client(client)
|
|
519
|
+
|
|
520
|
+
if reader_thread is not None and reader_thread is not threading.current_thread():
|
|
521
|
+
reader_thread.join(timeout=ANALYZER_READER_JOIN_TIMEOUT_SECONDS)
|
|
522
|
+
|
|
523
|
+
if close_client and client is not None:
|
|
524
|
+
self.close_jack_client(client)
|
|
525
|
+
self.client = None
|
|
526
|
+
self.left_input_port = None
|
|
527
|
+
self.right_input_port = None
|
|
528
|
+
|
|
529
|
+
def close(self) -> None:
|
|
530
|
+
self.stop(close_client=True)
|
|
531
|
+
|
|
532
|
+
def restart(self) -> bool:
|
|
533
|
+
self.stop(close_client=True)
|
|
534
|
+
if not self.enabled:
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
return self.start()
|
|
538
|
+
|
|
539
|
+
def open_jack_client(self):
|
|
540
|
+
try:
|
|
541
|
+
import jack
|
|
542
|
+
except Exception as exc:
|
|
543
|
+
raise RuntimeError("Python JACK client is not available") from exc
|
|
544
|
+
|
|
545
|
+
old_pipewire_props = os.environ.get("PIPEWIRE_PROPS")
|
|
546
|
+
os.environ["PIPEWIRE_PROPS"] = jack_pipewire_props(old_pipewire_props)
|
|
547
|
+
try:
|
|
548
|
+
client = jack.Client(JACK_CLIENT_NAME, no_start_server=True)
|
|
549
|
+
finally:
|
|
550
|
+
if old_pipewire_props is None:
|
|
551
|
+
os.environ.pop("PIPEWIRE_PROPS", None)
|
|
552
|
+
else:
|
|
553
|
+
os.environ["PIPEWIRE_PROPS"] = old_pipewire_props
|
|
554
|
+
|
|
555
|
+
self.sample_rate = float(client.samplerate or SAMPLE_RATE)
|
|
556
|
+
return client
|
|
557
|
+
|
|
558
|
+
def activate_jack_client(self, client) -> None:
|
|
559
|
+
if self.client_active:
|
|
560
|
+
disconnect_jack_input_port_connections(client, (self.left_input_port, self.right_input_port))
|
|
561
|
+
self.connect_jack_monitor_ports(client)
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if self.left_input_port is None:
|
|
565
|
+
self.left_input_port = client.inports.register(JACK_LEFT_INPUT_PORT, is_terminal=True)
|
|
566
|
+
if self.right_input_port is None:
|
|
567
|
+
self.right_input_port = client.inports.register(JACK_RIGHT_INPUT_PORT, is_terminal=True)
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
client.set_process_callback(self.process_jack_audio)
|
|
571
|
+
client.activate()
|
|
572
|
+
self.client_active = True
|
|
573
|
+
disconnect_jack_input_port_connections(client, (self.left_input_port, self.right_input_port))
|
|
574
|
+
self.connect_jack_monitor_ports(client)
|
|
575
|
+
except Exception:
|
|
576
|
+
self.deactivate_jack_client(client)
|
|
577
|
+
raise
|
|
578
|
+
|
|
579
|
+
def deactivate_jack_client(self, client) -> None:
|
|
580
|
+
try:
|
|
581
|
+
client.deactivate()
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
self.client_active = False
|
|
586
|
+
|
|
587
|
+
def close_jack_client(self, client) -> None:
|
|
588
|
+
if self.client_active:
|
|
589
|
+
self.deactivate_jack_client(client)
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
client.close()
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
def connect_jack_monitor_ports(self, client) -> None:
|
|
597
|
+
output_ports = jack_audio_output_ports_for_sink(
|
|
598
|
+
client.get_ports(is_audio=True, is_output=True),
|
|
599
|
+
self.output_sink_name,
|
|
600
|
+
self.output_sink_description,
|
|
601
|
+
)
|
|
602
|
+
left_output_port, right_output_port = select_jack_stereo_output_ports(output_ports)
|
|
603
|
+
|
|
604
|
+
if left_output_port is None or right_output_port is None:
|
|
605
|
+
sink_label = self.output_sink_description or self.output_sink_name
|
|
606
|
+
raise RuntimeError(f"JACK monitor ports not found for {sink_label}")
|
|
607
|
+
|
|
608
|
+
client.connect(jack_port_name(left_output_port), jack_port_name(self.left_input_port))
|
|
609
|
+
client.connect(jack_port_name(right_output_port), jack_port_name(self.right_input_port))
|
|
610
|
+
|
|
611
|
+
def process_jack_audio(self, _frames: int) -> None:
|
|
612
|
+
if self.stop_event.is_set() or self.left_input_port is None or self.right_input_port is None:
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
self.audio_blocks.append(
|
|
616
|
+
(
|
|
617
|
+
bytes(self.left_input_port.get_buffer()),
|
|
618
|
+
bytes(self.right_input_port.get_buffer()),
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def read_jack_levels(self) -> None:
|
|
623
|
+
pending_samples = array("f")
|
|
624
|
+
fft_samples = array("f")
|
|
625
|
+
fft_size = analyzer_fft_size(self.sample_rate)
|
|
626
|
+
smoothed_powers: tuple[float, ...] = ()
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
while not self.stop_event.is_set():
|
|
630
|
+
try:
|
|
631
|
+
left_payload, right_payload = self.audio_blocks.popleft()
|
|
632
|
+
except IndexError:
|
|
633
|
+
self.stop_event.wait(ANALYZER_QUEUE_WAIT_SECONDS)
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
pending_samples.extend(stereo_f32le_bytes_to_mono_samples(left_payload, right_payload))
|
|
637
|
+
frame_count = analyzer_frame_count(self.sample_rate)
|
|
638
|
+
|
|
639
|
+
while len(pending_samples) >= frame_count:
|
|
640
|
+
samples = pending_samples[:frame_count]
|
|
641
|
+
del pending_samples[:frame_count]
|
|
642
|
+
|
|
643
|
+
fft_samples.extend(samples)
|
|
644
|
+
if len(fft_samples) > fft_size:
|
|
645
|
+
del fft_samples[: len(fft_samples) - fft_size]
|
|
646
|
+
|
|
647
|
+
band_powers = samples_to_log_band_powers(
|
|
648
|
+
fft_samples,
|
|
649
|
+
sample_rate=self.sample_rate,
|
|
650
|
+
fft_size=fft_size,
|
|
651
|
+
)
|
|
652
|
+
alpha = analyzer_smoothing_alpha(self.response_speed, len(samples), self.sample_rate)
|
|
653
|
+
smoothed_powers = smooth_power_values(smoothed_powers, band_powers, alpha)
|
|
654
|
+
levels = spectrum_db_values_to_levels(power_values_to_db_values(smoothed_powers))
|
|
655
|
+
callback = self.levels_callback
|
|
656
|
+
if callback is not None and levels:
|
|
657
|
+
callback(levels)
|
|
658
|
+
finally:
|
|
659
|
+
if self.reader_thread is threading.current_thread():
|
|
660
|
+
self.reader_thread = None
|