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 ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.1"
4
+
5
+ __all__ = ["__version__"]
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