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.
- micforge/__init__.py +3 -0
- micforge/__main__.py +4 -0
- micforge/analysis/__init__.py +0 -0
- micforge/analysis/stats.py +133 -0
- micforge/audio/__init__.py +0 -0
- micforge/audio/backend.py +32 -0
- micforge/audio/cues.py +110 -0
- micforge/audio/device_watch.py +237 -0
- micforge/audio/devices.py +160 -0
- micforge/audio/engine.py +746 -0
- micforge/audio/fileio.py +45 -0
- micforge/audio/latency.py +169 -0
- micforge/audio/loopback.py +113 -0
- micforge/audio/metrics.py +134 -0
- micforge/audio/noise_profile.py +250 -0
- micforge/audio/recorder.py +100 -0
- micforge/audio/ring.py +89 -0
- micforge/cli.py +293 -0
- micforge/commands/__init__.py +0 -0
- micforge/commands/calibrate.py +47 -0
- micforge/commands/common.py +69 -0
- micforge/commands/devices_cmd.py +86 -0
- micforge/commands/diagnostics_cmd.py +68 -0
- micforge/commands/doctor.py +127 -0
- micforge/commands/files.py +48 -0
- micforge/commands/help_cmd.py +449 -0
- micforge/commands/latency_test.py +65 -0
- micforge/commands/live.py +321 -0
- micforge/commands/preset_cmd.py +142 -0
- micforge/commands/test_mic.py +98 -0
- micforge/commands/wizard.py +70 -0
- micforge/config.py +279 -0
- micforge/diagnostics.py +308 -0
- micforge/dsp/__init__.py +0 -0
- micforge/dsp/aec.py +388 -0
- micforge/dsp/base.py +30 -0
- micforge/dsp/biquad.py +144 -0
- micforge/dsp/chain.py +153 -0
- micforge/dsp/cofilite.py +427 -0
- micforge/dsp/compressor.py +109 -0
- micforge/dsp/deesser.py +64 -0
- micforge/dsp/denoise.py +181 -0
- micforge/dsp/deverb.py +227 -0
- micforge/dsp/export_cofilite.py +111 -0
- micforge/dsp/gate.py +76 -0
- micforge/dsp/leveler.py +74 -0
- micforge/dsp/proximity.py +164 -0
- micforge/dsp/util.py +80 -0
- micforge/errors.py +25 -0
- micforge/logging_setup.py +25 -0
- micforge/models/cofilite.onnx +0 -0
- micforge/presets.py +284 -0
- micforge/profile_store.py +41 -0
- micforge/ui/__init__.py +0 -0
- micforge/ui/dashboard.py +573 -0
- micforge/ui/hotkeys.py +169 -0
- micforge/ui/keys.py +64 -0
- micforge/ui/report.py +88 -0
- micforge/ui/settings_gui.py +899 -0
- micforge/ui/theme.py +42 -0
- micforge/ui/tray.py +276 -0
- micforge/ui/tuning.py +138 -0
- micforge/ui/widgets.py +109 -0
- micforge/util/autostart.py +56 -0
- micforge-1.3.0.dist-info/METADATA +334 -0
- micforge-1.3.0.dist-info/RECORD +70 -0
- micforge-1.3.0.dist-info/WHEEL +5 -0
- micforge-1.3.0.dist-info/entry_points.txt +2 -0
- micforge-1.3.0.dist-info/licenses/LICENSE +22 -0
- 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
|