micforge 1.3.0__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.
Files changed (70) hide show
  1. micforge/__init__.py +3 -0
  2. micforge/__main__.py +4 -0
  3. micforge/analysis/__init__.py +0 -0
  4. micforge/analysis/stats.py +133 -0
  5. micforge/audio/__init__.py +0 -0
  6. micforge/audio/backend.py +32 -0
  7. micforge/audio/cues.py +110 -0
  8. micforge/audio/device_watch.py +237 -0
  9. micforge/audio/devices.py +160 -0
  10. micforge/audio/engine.py +746 -0
  11. micforge/audio/fileio.py +45 -0
  12. micforge/audio/latency.py +169 -0
  13. micforge/audio/loopback.py +113 -0
  14. micforge/audio/metrics.py +134 -0
  15. micforge/audio/noise_profile.py +250 -0
  16. micforge/audio/recorder.py +100 -0
  17. micforge/audio/ring.py +89 -0
  18. micforge/cli.py +293 -0
  19. micforge/commands/__init__.py +0 -0
  20. micforge/commands/calibrate.py +47 -0
  21. micforge/commands/common.py +69 -0
  22. micforge/commands/devices_cmd.py +86 -0
  23. micforge/commands/diagnostics_cmd.py +68 -0
  24. micforge/commands/doctor.py +127 -0
  25. micforge/commands/files.py +48 -0
  26. micforge/commands/help_cmd.py +449 -0
  27. micforge/commands/latency_test.py +65 -0
  28. micforge/commands/live.py +321 -0
  29. micforge/commands/preset_cmd.py +142 -0
  30. micforge/commands/test_mic.py +98 -0
  31. micforge/commands/wizard.py +70 -0
  32. micforge/config.py +279 -0
  33. micforge/diagnostics.py +308 -0
  34. micforge/dsp/__init__.py +0 -0
  35. micforge/dsp/aec.py +388 -0
  36. micforge/dsp/base.py +30 -0
  37. micforge/dsp/biquad.py +144 -0
  38. micforge/dsp/chain.py +153 -0
  39. micforge/dsp/cofilite.py +427 -0
  40. micforge/dsp/compressor.py +109 -0
  41. micforge/dsp/deesser.py +64 -0
  42. micforge/dsp/denoise.py +181 -0
  43. micforge/dsp/deverb.py +227 -0
  44. micforge/dsp/export_cofilite.py +111 -0
  45. micforge/dsp/gate.py +76 -0
  46. micforge/dsp/leveler.py +74 -0
  47. micforge/dsp/proximity.py +164 -0
  48. micforge/dsp/util.py +80 -0
  49. micforge/errors.py +25 -0
  50. micforge/logging_setup.py +25 -0
  51. micforge/models/cofilite.onnx +0 -0
  52. micforge/presets.py +284 -0
  53. micforge/profile_store.py +41 -0
  54. micforge/ui/__init__.py +0 -0
  55. micforge/ui/dashboard.py +573 -0
  56. micforge/ui/hotkeys.py +169 -0
  57. micforge/ui/keys.py +64 -0
  58. micforge/ui/report.py +88 -0
  59. micforge/ui/settings_gui.py +899 -0
  60. micforge/ui/theme.py +42 -0
  61. micforge/ui/tray.py +276 -0
  62. micforge/ui/tuning.py +138 -0
  63. micforge/ui/widgets.py +109 -0
  64. micforge/util/autostart.py +56 -0
  65. micforge-1.3.0.dist-info/METADATA +334 -0
  66. micforge-1.3.0.dist-info/RECORD +70 -0
  67. micforge-1.3.0.dist-info/WHEEL +5 -0
  68. micforge-1.3.0.dist-info/entry_points.txt +2 -0
  69. micforge-1.3.0.dist-info/licenses/LICENSE +22 -0
  70. micforge-1.3.0.dist-info/top_level.txt +1 -0
micforge/dsp/aec.py ADDED
@@ -0,0 +1,388 @@
1
+ """Modular Acoustic Echo Cancellation (AEC) Module.
2
+
3
+ Eliminates speaker bleed, game audio, and teammate voice echoes leaking from
4
+ loudspeakers or open-back headphones into the microphone.
5
+
6
+ Architecture:
7
+ 1. Reference Loopback Buffer: Ingests system/speaker audio (far-end reference).
8
+ 2. Time-Delay Alignment: Aligns far-end reference with mic capture using
9
+ lookback cross-correlation.
10
+ 3. Partitioned Block Frequency-Domain Adaptive Filter (PBFDAF):
11
+ Linear adaptive filter using Normalized Least Mean Squares (NLMS) with
12
+ causality time-domain constraints.
13
+ 4. Double-Talk Detector (DTD): Cross-correlation and energy coherence check to
14
+ freeze adaptation when near-end speech is active, preventing filter divergence.
15
+ 5. Non-Linear Residual Post-Filter (RES): Spectral suppression of speaker distortion,
16
+ non-linear room resonances, and residual leakage.
17
+ 6. Ultra-low latency & CPU: Vectorized NumPy operations, pre-allocated scratch
18
+ buffers, zero memory allocation during streaming.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ from .base import Processor
28
+ from .util import EPS, db2lin, lin2db
29
+
30
+
31
+ class AECDelayEstimator:
32
+ """Estimates time-delay between far-end reference and microphone input."""
33
+
34
+ def __init__(self, sample_rate: int, max_delay_ms: float = 200.0) -> None:
35
+ self.sr = sample_rate
36
+ self.max_delay_samples = int(max_delay_ms * 1e-3 * sample_rate)
37
+ self.estimated_delay_samples = int(0.040 * sample_rate) # 40 ms default
38
+
39
+ def update_delay(self, ref: np.ndarray, mic: np.ndarray) -> int:
40
+ """Estimate delay using normalized cross-correlation peak on downsampled envelopes."""
41
+ if ref.size < 256 or mic.size < 256:
42
+ return self.estimated_delay_samples
43
+
44
+ # Fast envelope correlation downsampled by 8
45
+ ds = 8
46
+ ref_ds = np.abs(ref[::ds])
47
+ mic_ds = np.abs(mic[::ds])
48
+ max_lag = self.max_delay_samples // ds
49
+
50
+ corr = np.correlate(mic_ds, ref_ds, mode="full")
51
+ mid = len(ref_ds) - 1
52
+ valid_corr = corr[mid : mid + max_lag]
53
+ if valid_corr.size > 0:
54
+ best_lag = int(np.argmax(valid_corr)) * ds
55
+ # Smooth tracking: 95% previous, 5% new
56
+ self.estimated_delay_samples = int(0.95 * self.estimated_delay_samples + 0.05 * best_lag)
57
+
58
+ return self.estimated_delay_samples
59
+
60
+
61
+ class AECDoubleTalkDetector:
62
+ """Detects near-end speech to freeze adaptation and prevent filter divergence."""
63
+
64
+ def __init__(self) -> None:
65
+ self.dtd_active = False
66
+ self._hangover = 0
67
+
68
+ def check(self, mic_rms: float, ref_rms: float, err_rms: float) -> bool:
69
+ """Returns True if user voice (near-end) or double-talk is active."""
70
+ if mic_rms < 1e-4:
71
+ self.dtd_active = False
72
+ return False
73
+
74
+ # If microphone energy is significantly louder than speaker reference playback,
75
+ # near-end speech is active (user is talking into the mic).
76
+ is_near_end = mic_rms > (1.8 * max(ref_rms, 1e-5)) and mic_rms > 0.005
77
+
78
+ if is_near_end:
79
+ self._hangover = 6 # Hold DTD for ~6 blocks (60ms)
80
+ self.dtd_active = True
81
+ elif self._hangover > 0:
82
+ self._hangover -= 1
83
+ self.dtd_active = True
84
+ else:
85
+ self.dtd_active = False
86
+
87
+ return self.dtd_active
88
+
89
+
90
+ class AECResidualSuppressor:
91
+ """Non-linear spectral suppression for speaker distortion and residual echo."""
92
+
93
+ def __init__(self, n_bins: int, max_suppression_db: float = 24.0) -> None:
94
+ self.n_bins = n_bins
95
+ self.floor = db2lin(-max_suppression_db)
96
+ self._prior_gain = np.ones(n_bins, dtype=np.float32)
97
+
98
+ def process(
99
+ self,
100
+ err_spec: np.ndarray,
101
+ echo_psd: np.ndarray,
102
+ mic_psd: np.ndarray,
103
+ dtd_active: bool,
104
+ amount: float,
105
+ ) -> np.ndarray:
106
+ if amount <= 1e-3:
107
+ return err_spec
108
+
109
+ # Estimate residual echo power
110
+ err_psd = err_spec.real**2 + err_spec.imag**2
111
+
112
+ # When DTD is active (user is talking), reduce residual aggression to protect speech
113
+ agg = 1.6 if not dtd_active else 0.45
114
+ agg *= amount
115
+
116
+ # Wiener-type suppression mask
117
+ snr_est = np.maximum(0.0, err_psd - agg * echo_psd) / (err_psd + EPS)
118
+ gain = np.clip(snr_est, self.floor, 1.0)
119
+
120
+ # Smooth gain across frequency to avoid tone burrs
121
+ smoothed = np.empty_like(gain)
122
+ smoothed[0] = 0.7 * gain[0] + 0.3 * gain[1]
123
+ smoothed[1:-1] = 0.25 * gain[:-2] + 0.5 * gain[1:-1] + 0.25 * gain[2:]
124
+ smoothed[-1] = 0.3 * gain[-2] + 0.7 * gain[-1]
125
+
126
+ # Recursive smoothing
127
+ self._prior_gain = 0.4 * self._prior_gain + 0.6 * smoothed
128
+ return err_spec * self._prior_gain
129
+
130
+
131
+ class AcousticEchoCanceller(Processor):
132
+ name = "aec"
133
+
134
+ def __init__(
135
+ self,
136
+ sample_rate: int,
137
+ amount: float = 1.0,
138
+ filter_length_ms: float = 60.0,
139
+ delay_ms: float = 40.0,
140
+ suppression_db: float = 24.0,
141
+ enabled: bool = True,
142
+ ) -> None:
143
+ super().__init__(sample_rate, enabled)
144
+ self.amount = float(min(1.0, max(0.0, amount)))
145
+ self.filter_length_ms = float(min(200.0, max(20.0, filter_length_ms)))
146
+ self.delay_ms = float(min(250.0, max(0.0, delay_ms)))
147
+ self.suppression_db = float(min(40.0, max(6.0, suppression_db)))
148
+
149
+ # Sub-band PBFDAF block parameters
150
+ self.N = 128 # Sub-block size (2.67 ms @ 48 kHz)
151
+ self.K = self.N + 1 # RFFT bins (129)
152
+ self.P = max(2, min(16, int(self.filter_length_ms * 1e-3 * sample_rate / self.N))) # Partitions
153
+
154
+ # Adaptive filter state
155
+ self.W = np.zeros((self.P, self.K), dtype=np.complex64)
156
+ self.X_hist = np.zeros((self.P, self.K), dtype=np.complex64)
157
+ self.P_x = np.full(self.K, 1e-3, dtype=np.float32)
158
+
159
+ # Reference audio ring buffer (holds up to 1 second of far-end speaker audio)
160
+ self._ref_cap = sample_rate
161
+ self._ref_ring = np.zeros(self._ref_cap, dtype=np.float32)
162
+ self._ref_w = 0
163
+ self._ref_r = 0
164
+ self._ref_available = False
165
+ self._mic_processed = 0
166
+
167
+ # Input and output alignment buffers
168
+ self._in_mic = np.zeros(self.N * 8, dtype=np.float32)
169
+ self._in_mic_len = 0
170
+ self._out_buf = np.zeros(self.N * 8, dtype=np.float32)
171
+ self._out_len = 0
172
+
173
+ # Submodules
174
+ self.delay_est = AECDelayEstimator(sample_rate, max_delay_ms=250.0)
175
+ self.dtd = AECDoubleTalkDetector()
176
+ self.post_filter = AECResidualSuppressor(self.N // 2 + 1, max_suppression_db=self.suppression_db)
177
+
178
+ # Pre-allocated scratch buffers
179
+ self._scratch_ref_block = np.zeros(self.N * 2, dtype=np.float32)
180
+ self._scratch_mic_block = np.zeros(self.N, dtype=np.float32)
181
+ self._scratch_echo_est = np.zeros(self.N, dtype=np.float32)
182
+ self._scratch_err_time = np.zeros(self.N, dtype=np.float32)
183
+
184
+ # Telemetry
185
+ self._erle_db = 0.0
186
+ self._dtd_active = False
187
+ self._ref_energy_db = -90.0
188
+ self._echo_detected = False
189
+
190
+ def feed_reference(self, ref_audio: np.ndarray) -> None:
191
+ """Feed speaker / game / Discord reference audio into the AEC loopback buffer."""
192
+ if ref_audio.size == 0:
193
+ return
194
+ x = np.asarray(ref_audio, dtype=np.float32)
195
+ if x.ndim > 1:
196
+ x = x[:, 0] # Mono
197
+
198
+ n = x.shape[0]
199
+ w = self._ref_w
200
+ cap = self._ref_cap
201
+
202
+ first = min(n, cap - (w % cap))
203
+ w_idx = w % cap
204
+ self._ref_ring[w_idx : w_idx + first] = x[:first]
205
+ if first < n:
206
+ self._ref_ring[: n - first] = x[first:]
207
+ self._ref_w = w + n
208
+ self._ref_available = True
209
+
210
+ # Track reference energy
211
+ ref_rms = float(np.sqrt(np.mean(x * x)))
212
+ self._ref_energy_db = float(lin2db(max(ref_rms, 1e-6)))
213
+
214
+ def set_amount(self, amount: float) -> None:
215
+ self.amount = float(min(1.0, max(0.0, amount)))
216
+
217
+ def set_delay_ms(self, ms: float) -> None:
218
+ self.delay_ms = float(min(250.0, max(0.0, ms)))
219
+
220
+ def set_suppression_db(self, db: float) -> None:
221
+ self.suppression_db = float(min(40.0, max(6.0, db)))
222
+ self.post_filter.floor = db2lin(-self.suppression_db)
223
+
224
+ @property
225
+ def latency_samples(self) -> int:
226
+ return self.N
227
+
228
+ def reset(self) -> None:
229
+ self.W.fill(0.0)
230
+ self.X_hist.fill(0.0)
231
+ self.P_x.fill(1e-3)
232
+ self._ref_ring.fill(0.0)
233
+ self._ref_w = 0
234
+ self._ref_r = 0
235
+ self._ref_available = False
236
+ self._mic_processed = 0
237
+ self._in_mic.fill(0.0)
238
+ self._in_mic_len = 0
239
+ self._out_buf.fill(0.0)
240
+ self._out_len = 0
241
+ self._erle_db = 0.0
242
+ self._dtd_active = False
243
+ self._ref_energy_db = -90.0
244
+ self._echo_detected = False
245
+
246
+ def process(self, x: np.ndarray) -> np.ndarray:
247
+ if not self.enabled or self.amount <= 1e-4 or x.size == 0:
248
+ return x
249
+
250
+ n_in = x.shape[0]
251
+
252
+ # If no reference audio has ever been fed or reference is absolute silence (< -70 dBFS),
253
+ # pass mic audio through cleanly with zero processing overhead.
254
+ if not self._ref_available or self._ref_energy_db < -70.0:
255
+ self._erle_db = 0.0
256
+ self._echo_detected = False
257
+ return x
258
+
259
+ # 1. Accumulate incoming mic samples
260
+ if self._in_mic_len + n_in > self._in_mic.shape[0]:
261
+ new_mic = np.zeros(max(self._in_mic.shape[0] * 2, self._in_mic_len + n_in + 1024), dtype=np.float32)
262
+ new_mic[: self._in_mic_len] = self._in_mic[: self._in_mic_len]
263
+ self._in_mic = new_mic
264
+
265
+ self._in_mic[self._in_mic_len : self._in_mic_len + n_in] = x
266
+ self._in_mic_len += n_in
267
+
268
+ # Delay compensation offset
269
+ delay_samples = int(self.delay_ms * 1e-3 * self.sample_rate)
270
+
271
+ # 2. Process sub-blocks of size N (128 samples = 2.67 ms)
272
+ step_mu = 0.06 * self.amount
273
+
274
+ while self._in_mic_len >= self.N:
275
+ # Current mic block
276
+ mic_block = self._in_mic[: self.N].copy()
277
+
278
+ # Shift input mic buffer
279
+ remain = self._in_mic_len - self.N
280
+ self._in_mic[:remain] = self._in_mic[self.N : self._in_mic_len]
281
+ self._in_mic_len = remain
282
+
283
+ # Extract aligned reference block of size 2N (overlap-save)
284
+ aligned_ref_idx = self._mic_processed - delay_samples
285
+ self._mic_processed += self.N
286
+
287
+ if aligned_ref_idx >= 0 and self._ref_w >= aligned_ref_idx + (self.N * 2):
288
+ # Pull 2N samples from circular reference ring
289
+ cap = self._ref_cap
290
+ r_idx = aligned_ref_idx % cap
291
+ first = min(self.N * 2, cap - r_idx)
292
+ self._scratch_ref_block[:first] = self._ref_ring[r_idx : r_idx + first]
293
+ if first < self.N * 2:
294
+ self._scratch_ref_block[first:] = self._ref_ring[: (self.N * 2) - first]
295
+ else:
296
+ self._scratch_ref_block.fill(0.0)
297
+
298
+ # PBFDAF: Shift frequency history
299
+ self.X_hist[1:] = self.X_hist[:-1]
300
+ X_curr = np.fft.rfft(self._scratch_ref_block, n=self.N * 2)
301
+ self.X_hist[0] = X_curr
302
+
303
+ # Estimate echo in frequency domain: D_hat = sum(W_p * X_p)
304
+ D_hat_spec = np.sum(self.W * self.X_hist, axis=0)
305
+ d_hat_time = np.fft.irfft(D_hat_spec, n=self.N * 2).real[self.N :]
306
+
307
+ # Linear error: e = y - d_hat
308
+ err_time = mic_block - d_hat_time
309
+
310
+ # Compute energies for Double-Talk Detection & ERLE
311
+ mic_rms = float(np.sqrt(np.mean(mic_block * mic_block)))
312
+ echo_rms = float(np.sqrt(np.mean(d_hat_time * d_hat_time)))
313
+ err_rms = float(np.sqrt(np.mean(err_time * err_time)))
314
+ ref_rms = float(np.sqrt(np.mean(self._scratch_ref_block * self._scratch_ref_block)))
315
+
316
+ dtd_active = self.dtd.check(mic_rms, ref_rms, err_rms)
317
+ self._dtd_active = dtd_active
318
+
319
+ # Update normalized reference power estimate P_x per bin
320
+ self.P_x = 0.95 * self.P_x + 0.05 * (X_curr.real**2 + X_curr.imag**2) / (self.N * 2)
321
+
322
+ # Adaptive filter weight update (only when NOT in double-talk)
323
+ if not dtd_active and ref_rms > 1e-4:
324
+ # Frequency error: zero-padded [0, err_time]
325
+ err_ext = np.zeros(self.N * 2, dtype=np.float32)
326
+ err_ext[self.N :] = err_time
327
+ E_spec = np.fft.rfft(err_ext, n=self.N * 2)
328
+
329
+ # Normalized LMS step size per bin with gradient constraint
330
+ norm = (self.P * self.P_x + 1e-2) * (self.N * 2)
331
+ mu_k = step_mu / norm
332
+
333
+ for p in range(self.P):
334
+ grad = np.conj(self.X_hist[p]) * E_spec
335
+ self.W[p] += mu_k * grad
336
+
337
+ # Time-domain constraint: zero non-causal tail to maintain FIR stability
338
+ w_time = np.fft.irfft(self.W[p], n=self.N * 2).real
339
+ w_time[self.N :] = 0.0
340
+ self.W[p] = np.fft.rfft(w_time, n=self.N * 2)
341
+
342
+ self._echo_detected = True
343
+ else:
344
+ self._echo_detected = False
345
+
346
+ # Non-linear residual suppression post-filter
347
+ err_spec = np.fft.rfft(err_time, n=self.N)
348
+ echo_psd = np.abs(np.fft.rfft(d_hat_time, n=self.N)) ** 2
349
+ mic_psd = np.abs(np.fft.rfft(mic_block, n=self.N)) ** 2
350
+
351
+ clean_spec = self.post_filter.process(
352
+ err_spec, echo_psd, mic_psd, dtd_active, self.amount
353
+ )
354
+ clean_time = np.fft.irfft(clean_spec, n=self.N).real
355
+
356
+ # Telemetry ERLE (Echo Return Loss Enhancement in dB)
357
+ clean_rms = float(np.sqrt(np.mean(clean_time * clean_time)))
358
+ ratio = max(mic_rms, 1e-6) / max(clean_rms, 1e-6)
359
+ erle = 10.0 * math.log10(ratio) if ratio > 0 else 0.0
360
+ self._erle_db = 0.9 * self._erle_db + 0.1 * max(0.0, min(40.0, erle))
361
+
362
+ # Append to output buffer
363
+ if self._out_len + self.N > self._out_buf.shape[0]:
364
+ new_out = np.zeros(max(self._out_buf.shape[0] * 2, self._out_len + self.N + 1024), dtype=np.float32)
365
+ new_out[: self._out_len] = self._out_buf[: self._out_len]
366
+ self._out_buf = new_out
367
+
368
+ self._out_buf[self._out_len : self._out_len + self.N] = clean_time
369
+ self._out_len += self.N
370
+
371
+ # 3. Pull required samples matching input block length
372
+ if self._out_len >= n_in:
373
+ chunk = self._out_buf[:n_in].copy()
374
+ remain_out = self._out_len - n_in
375
+ if remain_out > 0:
376
+ self._out_buf[:remain_out] = self._out_buf[n_in : self._out_len]
377
+ self._out_len = remain_out
378
+ return chunk
379
+ else:
380
+ return x # Pass through if buffer is still priming
381
+
382
+ def telemetry(self) -> dict[str, float]:
383
+ return {
384
+ "erle_db": self._erle_db,
385
+ "dtd_active": 1.0 if self._dtd_active else 0.0,
386
+ "ref_energy_db": self._ref_energy_db,
387
+ "echo_detected": 1.0 if self._echo_detected else 0.0,
388
+ }
micforge/dsp/base.py ADDED
@@ -0,0 +1,30 @@
1
+ """Processor interface: one mono float32 block in, same-length block out."""
2
+ from __future__ import annotations
3
+
4
+ import abc
5
+
6
+ import numpy as np
7
+
8
+
9
+ class Processor(abc.ABC):
10
+ name: str = "processor"
11
+
12
+ def __init__(self, sample_rate: int, enabled: bool = True) -> None:
13
+ self.sample_rate = int(sample_rate)
14
+ self.enabled = enabled
15
+
16
+ @property
17
+ def latency_samples(self) -> int:
18
+ """Algorithmic delay introduced by this stage."""
19
+ return 0
20
+
21
+ @abc.abstractmethod
22
+ def process(self, x: np.ndarray) -> np.ndarray:
23
+ """Process one block. Must return an array of the same length."""
24
+
25
+ def reset(self) -> None:
26
+ """Clear internal state."""
27
+
28
+ def telemetry(self) -> dict[str, float]:
29
+ """Live values for the dashboard (gain reduction, etc.)."""
30
+ return {}
micforge/dsp/biquad.py ADDED
@@ -0,0 +1,144 @@
1
+ """RBJ-cookbook biquads, run as streaming second-order sections."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from typing import Iterable, Protocol
6
+
7
+ import numpy as np
8
+
9
+ from .base import Processor
10
+
11
+
12
+ def sosfilt(sos: np.ndarray, x: np.ndarray, zi: np.ndarray | None = None) -> tuple[np.ndarray, np.ndarray]:
13
+ """Pure NumPy Direct Form II Transposed Second-Order Sections (biquad) filter.
14
+
15
+ Computes identical numerical output to scipy.signal.sosfilt without requiring
16
+ the 65 MB memory footprint of loading compiled SciPy BLAS/LAPACK runtime DLLs.
17
+ """
18
+ sos = np.atleast_2d(np.asarray(sos, dtype=np.float64))
19
+ n_sections = sos.shape[0]
20
+ if zi is None:
21
+ zi_out = np.zeros((n_sections, 2), dtype=np.float64)
22
+ else:
23
+ zi_out = np.array(zi, dtype=np.float64, copy=True)
24
+
25
+ y = np.array(x, dtype=np.float64, copy=True)
26
+ for s in range(n_sections):
27
+ b0, b1, b2, _, a1, a2 = sos[s]
28
+ z1, z2 = zi_out[s, 0], zi_out[s, 1]
29
+ for i in range(len(y)):
30
+ xi = y[i]
31
+ yi = b0 * xi + z1
32
+ z1 = b1 * xi - a1 * yi + z2
33
+ z2 = b2 * xi - a2 * yi
34
+ y[i] = yi
35
+ zi_out[s, 0] = z1
36
+ zi_out[s, 1] = z2
37
+ return y.astype(np.float32, copy=False), zi_out
38
+
39
+
40
+ class BandLike(Protocol):
41
+ kind: str
42
+ freq: float
43
+ gain_db: float
44
+ q: float
45
+
46
+
47
+ def design_biquad(kind: str, fs: float, f0: float, q: float = 0.7071, gain_db: float = 0.0) -> np.ndarray:
48
+ """Return one normalised SOS row [b0 b1 b2 1 a1 a2]."""
49
+ f0 = min(max(f0, 10.0), 0.49 * fs)
50
+ w0 = 2.0 * math.pi * f0 / fs
51
+ cw, sw = math.cos(w0), math.sin(w0)
52
+ alpha = sw / (2.0 * q)
53
+ A = 10.0 ** (gain_db / 40.0)
54
+
55
+ if kind == "lowpass":
56
+ b = [(1 - cw) / 2, 1 - cw, (1 - cw) / 2]
57
+ a = [1 + alpha, -2 * cw, 1 - alpha]
58
+ elif kind == "highpass":
59
+ b = [(1 + cw) / 2, -(1 + cw), (1 + cw) / 2]
60
+ a = [1 + alpha, -2 * cw, 1 - alpha]
61
+ elif kind == "bandpass":
62
+ b = [alpha, 0.0, -alpha]
63
+ a = [1 + alpha, -2 * cw, 1 - alpha]
64
+ elif kind == "peak":
65
+ b = [1 + alpha * A, -2 * cw, 1 - alpha * A]
66
+ a = [1 + alpha / A, -2 * cw, 1 - alpha / A]
67
+ elif kind in ("lowshelf", "highshelf"):
68
+ beta = 2.0 * math.sqrt(A) * alpha
69
+ if kind == "lowshelf":
70
+ b = [
71
+ A * ((A + 1) - (A - 1) * cw + beta),
72
+ 2 * A * ((A - 1) - (A + 1) * cw),
73
+ A * ((A + 1) - (A - 1) * cw - beta),
74
+ ]
75
+ a = [(A + 1) + (A - 1) * cw + beta, -2 * ((A - 1) + (A + 1) * cw), (A + 1) + (A - 1) * cw - beta]
76
+ else:
77
+ b = [
78
+ A * ((A + 1) + (A - 1) * cw + beta),
79
+ -2 * A * ((A - 1) + (A + 1) * cw),
80
+ A * ((A + 1) + (A - 1) * cw - beta),
81
+ ]
82
+ a = [(A + 1) - (A - 1) * cw + beta, 2 * ((A - 1) - (A + 1) * cw), (A + 1) - (A - 1) * cw - beta]
83
+ else:
84
+ raise ValueError(f"unknown filter kind: {kind!r}")
85
+
86
+ a0 = a[0]
87
+ return np.array([b[0] / a0, b[1] / a0, b[2] / a0, 1.0, a[1] / a0, a[2] / a0])
88
+
89
+
90
+ class BiquadFilter(Processor):
91
+ """A cascade of biquads with persistent state (safe for block-wise streaming)."""
92
+
93
+ def __init__(self, sample_rate: int, sos: np.ndarray, name: str = "biquad", enabled: bool = True) -> None:
94
+ super().__init__(sample_rate, enabled)
95
+ self.name = name
96
+ self.sos = np.atleast_2d(np.asarray(sos, dtype=np.float64))
97
+ self._zi = np.zeros((self.sos.shape[0], 2), dtype=np.float64)
98
+ self._scratch = np.zeros(0, dtype=np.float64)
99
+
100
+ @classmethod
101
+ def from_bands(
102
+ cls, sample_rate: int, bands: Iterable[BandLike], name: str = "eq", enabled: bool = True
103
+ ) -> "BiquadFilter":
104
+ rows = [design_biquad(b.kind, sample_rate, b.freq, b.q, b.gain_db) for b in bands]
105
+ if not rows: # identity section keeps shapes valid
106
+ rows = [np.array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]
107
+ return cls(sample_rate, np.vstack(rows), name=name, enabled=enabled)
108
+
109
+ @classmethod
110
+ def highpass(cls, sample_rate: int, freq: float, order: int = 4, name: str = "highpass", enabled: bool = True):
111
+ """Butterworth-ish high-pass built from cascaded biquads (order 2 per section)."""
112
+ sections = max(1, order // 2)
113
+ # Butterworth Q values for an even order
114
+ n = 2 * sections
115
+ qs = [1.0 / (2.0 * math.cos(math.pi * (2 * k + 1) / (2 * n))) for k in range(sections)]
116
+ rows = [design_biquad("highpass", sample_rate, max(freq, 10.0), q) for q in qs]
117
+ return cls(sample_rate, np.vstack(rows), name=name, enabled=enabled)
118
+
119
+ def process(self, x: np.ndarray) -> np.ndarray:
120
+ n = x.shape[0]
121
+ if self._scratch.shape[0] != n:
122
+ self._scratch = np.empty(n, dtype=np.float64)
123
+ self._scratch[:] = x
124
+
125
+ y = self._scratch
126
+ zi = self._zi
127
+ sos = self.sos
128
+ for s in range(sos.shape[0]):
129
+ b0, b1, b2, _, a1, a2 = sos[s]
130
+ z1 = zi[s, 0]
131
+ z2 = zi[s, 1]
132
+ for i in range(n):
133
+ xi = y[i]
134
+ yi = b0 * xi + z1
135
+ z1 = b1 * xi - a1 * yi + z2
136
+ z2 = b2 * xi - a2 * yi
137
+ y[i] = yi
138
+ zi[s, 0] = z1
139
+ zi[s, 1] = z2
140
+
141
+ return y.astype(np.float32, copy=False)
142
+
143
+ def reset(self) -> None:
144
+ self._zi[:] = 0.0