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/audio/engine.py
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""Real-time engine: mic -> DSP chain -> one or more output sinks (virtual cable, headphones).
|
|
2
|
+
|
|
3
|
+
Threading model
|
|
4
|
+
---------------
|
|
5
|
+
* The input callback (PortAudio thread) does all DSP, so there is exactly one hop of buffering.
|
|
6
|
+
* Each sink owns an output stream whose callback pulls from a ring buffer, with 1-sample
|
|
7
|
+
drift correction because input/output devices run on independent clocks.
|
|
8
|
+
* The UI thread only reads `snapshot()` (immutable) and flips simple flags.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ..dsp.chain import Chain
|
|
21
|
+
from ..dsp.denoise import SpectralDenoiser
|
|
22
|
+
from ..dsp.util import db2lin, rms_db
|
|
23
|
+
from ..errors import DeviceError
|
|
24
|
+
from .backend import get_sd
|
|
25
|
+
from .cues import CuePlayer
|
|
26
|
+
from .device_watch import DeviceEventType, DeviceWatcher, DeviceWatchEvent
|
|
27
|
+
from .devices import DeviceInfo, extra_settings
|
|
28
|
+
from .loopback import LoopbackCapture
|
|
29
|
+
from .metrics import LevelMeter, LevelSnapshot, SpectrumAnalyzer
|
|
30
|
+
from .noise_profile import AdaptiveNoiseProfiler, NoiseProfile
|
|
31
|
+
from .ring import RingBuffer
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("micforge.engine")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SinkSpec:
|
|
38
|
+
name: str # "cable" | "monitor"
|
|
39
|
+
device: DeviceInfo
|
|
40
|
+
enabled: bool = True
|
|
41
|
+
gain_db: float = 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class CalibrationResult:
|
|
46
|
+
psd: np.ndarray | None
|
|
47
|
+
noise_db: float
|
|
48
|
+
gate_threshold_db: float
|
|
49
|
+
n_fft: int
|
|
50
|
+
sample_rate: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class EngineSnapshot:
|
|
55
|
+
running: bool
|
|
56
|
+
in_level: LevelSnapshot
|
|
57
|
+
out_level: LevelSnapshot
|
|
58
|
+
in_bands: np.ndarray
|
|
59
|
+
out_bands: np.ndarray
|
|
60
|
+
telemetry: dict[str, dict[str, float]]
|
|
61
|
+
cpu_load: float
|
|
62
|
+
cpu_peak: float
|
|
63
|
+
input_overflows: int
|
|
64
|
+
output_underruns: int
|
|
65
|
+
drift_corrections: int
|
|
66
|
+
errors: int
|
|
67
|
+
latency_ms: float
|
|
68
|
+
bypass: bool
|
|
69
|
+
muted: bool
|
|
70
|
+
monitor_on: bool
|
|
71
|
+
monitor_available: bool
|
|
72
|
+
calibrating: float | None
|
|
73
|
+
input_gain_db: float
|
|
74
|
+
sinks: dict[str, bool] = field(default_factory=dict)
|
|
75
|
+
ref_level: LevelSnapshot | None = None # Level of speaker/game loopback audio reference
|
|
76
|
+
noise_profile: Any | None = None
|
|
77
|
+
recovering: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OutputSink:
|
|
81
|
+
def __init__(self, spec: SinkSpec, sample_rate: int, block_size: int, prefill_blocks: float | int) -> None:
|
|
82
|
+
self.spec = spec
|
|
83
|
+
self.sr = sample_rate
|
|
84
|
+
self.block = block_size
|
|
85
|
+
self.base_prefill = int(prefill_blocks * block_size)
|
|
86
|
+
self.prefill = self.base_prefill
|
|
87
|
+
self.max_prefill = int(max(3.5, float(prefill_blocks) * 2.0) * block_size)
|
|
88
|
+
self.ring = RingBuffer(sample_rate) # 1 s of headroom
|
|
89
|
+
self.enabled = spec.enabled
|
|
90
|
+
self.gain = db2lin(spec.gain_db)
|
|
91
|
+
self._gain_is_unity = abs(self.gain - 1.0) < 1e-6
|
|
92
|
+
self.underruns = 0
|
|
93
|
+
self.corrections = 0
|
|
94
|
+
self.stream: Any = None
|
|
95
|
+
self.channels = max(1, min(2, spec.device.max_output))
|
|
96
|
+
self.target_fill = float(self.prefill + 0.5 * self.block)
|
|
97
|
+
self._avg_fill = float(self.target_fill)
|
|
98
|
+
self._calibrated_phase = False
|
|
99
|
+
self._drift_cooldown = 0
|
|
100
|
+
self._active = False
|
|
101
|
+
self._consecutive_underruns = 0
|
|
102
|
+
self._last_underrun_time = 0.0
|
|
103
|
+
self._needs_fade_in = False
|
|
104
|
+
self._fade_samples = min(32, block_size // 4)
|
|
105
|
+
self._stable_blocks = 0
|
|
106
|
+
# Pre-allocated read buffer to avoid allocation in the output callback
|
|
107
|
+
self._read_buf = np.zeros(block_size + 2, dtype=np.float32)
|
|
108
|
+
# Pre-allocated silence cushion to avoid allocation in underrun path
|
|
109
|
+
self._silence = np.zeros(self.max_prefill, dtype=np.float32)
|
|
110
|
+
# Track the last sample output for continuity checking
|
|
111
|
+
self._last_sample = np.float32(0.0)
|
|
112
|
+
|
|
113
|
+
def open(self) -> None:
|
|
114
|
+
sd = get_sd()
|
|
115
|
+
try:
|
|
116
|
+
self.stream = sd.OutputStream(
|
|
117
|
+
device=self.spec.device.index,
|
|
118
|
+
samplerate=self.sr,
|
|
119
|
+
blocksize=self.block,
|
|
120
|
+
channels=self.channels,
|
|
121
|
+
dtype="float32",
|
|
122
|
+
latency="low",
|
|
123
|
+
callback=self._callback,
|
|
124
|
+
extra_settings=extra_settings(self.spec.device, "output"),
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc: # sd.PortAudioError and friends
|
|
127
|
+
raise DeviceError(
|
|
128
|
+
f"Cannot open output '{self.spec.device.name}' at {self.sr} Hz: {exc}",
|
|
129
|
+
hint="Try another device, or set sample_rate to 44100 in the config.",
|
|
130
|
+
) from exc
|
|
131
|
+
self._prime()
|
|
132
|
+
|
|
133
|
+
def _prime(self) -> None:
|
|
134
|
+
self.ring.clear()
|
|
135
|
+
self.prefill = self.base_prefill
|
|
136
|
+
self.target_fill = float(self.prefill + 0.5 * self.block)
|
|
137
|
+
self._avg_fill = float(self.target_fill)
|
|
138
|
+
self._drift_cooldown = -50
|
|
139
|
+
self._consecutive_underruns = 0
|
|
140
|
+
self._last_underrun_time = 0.0
|
|
141
|
+
self._needs_fade_in = False
|
|
142
|
+
self._stable_blocks = 0
|
|
143
|
+
self._active = False
|
|
144
|
+
self._last_sample = np.float32(0.0)
|
|
145
|
+
self._calibrated_phase = False
|
|
146
|
+
|
|
147
|
+
def start(self) -> None:
|
|
148
|
+
self.stream.start()
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
self._active = False
|
|
152
|
+
if self.stream is not None:
|
|
153
|
+
try:
|
|
154
|
+
self.stream.stop()
|
|
155
|
+
self.stream.close()
|
|
156
|
+
except Exception: # noqa: BLE001 - closing must never raise
|
|
157
|
+
log.debug("sink close failed", exc_info=True)
|
|
158
|
+
self.stream = None
|
|
159
|
+
|
|
160
|
+
def set_enabled(self, on: bool) -> None:
|
|
161
|
+
if on and not self.enabled:
|
|
162
|
+
self._prime()
|
|
163
|
+
self.enabled = on
|
|
164
|
+
|
|
165
|
+
def notify_pipeline_change(self) -> None:
|
|
166
|
+
"""Reset drift detector cooldown and alignment on preset or model swap.
|
|
167
|
+
|
|
168
|
+
Prevents the drift detector from misinterpreting a brief DSP swap
|
|
169
|
+
pause as a physical hardware clock drift, stopping false clock fixes.
|
|
170
|
+
"""
|
|
171
|
+
self.target_fill = float(self.prefill + 0.5 * self.block)
|
|
172
|
+
self._avg_fill = float(self.target_fill)
|
|
173
|
+
self._drift_cooldown = 0
|
|
174
|
+
self._consecutive_underruns = 0
|
|
175
|
+
self._stable_blocks = 0
|
|
176
|
+
self._calibrated_phase = False
|
|
177
|
+
|
|
178
|
+
def push(self, x: np.ndarray) -> None:
|
|
179
|
+
if self.enabled:
|
|
180
|
+
if not self._active:
|
|
181
|
+
# Arm the output sink with jitter cushion on the first incoming audio block
|
|
182
|
+
self.ring.clear()
|
|
183
|
+
self.ring.write(self._silence[:self.prefill])
|
|
184
|
+
self.target_fill = float(self.prefill + 0.5 * self.block)
|
|
185
|
+
self._avg_fill = float(self.target_fill)
|
|
186
|
+
self._drift_cooldown = -50
|
|
187
|
+
self.underruns = 0
|
|
188
|
+
self._active = True
|
|
189
|
+
self._calibrated_phase = False
|
|
190
|
+
# Avoid allocation when gain is unity (0 dB) — very common case
|
|
191
|
+
if self._gain_is_unity:
|
|
192
|
+
self.ring.write(x)
|
|
193
|
+
else:
|
|
194
|
+
self.ring.write(x * np.float32(self.gain))
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def latency_s(self) -> float:
|
|
198
|
+
try:
|
|
199
|
+
return float(self.stream.latency) if self.stream is not None else 0.0
|
|
200
|
+
except Exception: # noqa: BLE001
|
|
201
|
+
return 0.0
|
|
202
|
+
|
|
203
|
+
def _callback(self, outdata: np.ndarray, frames: int, _time: Any, status: Any) -> None:
|
|
204
|
+
if not self.enabled or not self._active:
|
|
205
|
+
outdata.fill(0)
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
fill = self.ring.fill
|
|
209
|
+
# Smooth low-pass filter (tau ~ 5s) tracks true hardware crystal drift, not momentary block variance
|
|
210
|
+
self._avg_fill = 0.998 * self._avg_fill + 0.002 * fill
|
|
211
|
+
self._drift_cooldown += 1
|
|
212
|
+
|
|
213
|
+
want = frames
|
|
214
|
+
deadband = int(1.0 * frames)
|
|
215
|
+
|
|
216
|
+
# Dynamic phase calibration: after 100 blocks (~1.0s) of stable audio,
|
|
217
|
+
# anchor target_fill directly to the true hardware steady-state fill.
|
|
218
|
+
# This completely eliminates false clock fixes caused by startup buffer offsets.
|
|
219
|
+
if not self._calibrated_phase and self._stable_blocks >= 100:
|
|
220
|
+
self.target_fill = float(self._avg_fill)
|
|
221
|
+
self._calibrated_phase = True
|
|
222
|
+
self._drift_cooldown = 0
|
|
223
|
+
|
|
224
|
+
target_fill = self.target_fill
|
|
225
|
+
|
|
226
|
+
# Clock drift: smooth drift tracking with 1.5s cooldown (150 blocks @ 10 ms).
|
|
227
|
+
# Only nudges read length by 1 sample when there is genuine, sustained crystal drift
|
|
228
|
+
# outside the 1.0-block (480-sample / 10ms) deadband.
|
|
229
|
+
if self._drift_cooldown >= 150 and self._calibrated_phase:
|
|
230
|
+
if self._avg_fill > target_fill + deadband:
|
|
231
|
+
want = frames + 1
|
|
232
|
+
self._drift_cooldown = 0
|
|
233
|
+
elif self._avg_fill < target_fill - deadband and fill >= frames:
|
|
234
|
+
want = frames - 1
|
|
235
|
+
self._drift_cooldown = 0
|
|
236
|
+
# Ensure pre-allocated read buffer is large enough
|
|
237
|
+
if self._read_buf.shape[0] < want:
|
|
238
|
+
self._read_buf = np.zeros(want + 2, dtype=np.float32)
|
|
239
|
+
else:
|
|
240
|
+
self._read_buf[:want] = 0.0
|
|
241
|
+
|
|
242
|
+
got = self.ring.read_into(self._read_buf[:want])
|
|
243
|
+
|
|
244
|
+
# CRITICAL FIX: An underrun ONLY occurs if we got fewer samples than we requested (got < want).
|
|
245
|
+
# Previously, checking `got < frames` when `want == frames - 1` misidentified intentional
|
|
246
|
+
# 1-sample drift corrections as underruns, triggering a cascading starvation loop!
|
|
247
|
+
if got < want:
|
|
248
|
+
self.underruns += 1
|
|
249
|
+
now = time.monotonic()
|
|
250
|
+
|
|
251
|
+
# 1. Pop concealment: crossfade from last known sample to silence
|
|
252
|
+
if got > 0:
|
|
253
|
+
flen = min(got, self._fade_samples)
|
|
254
|
+
fade = np.arange(flen, 0, -1, dtype=np.float32) / flen
|
|
255
|
+
self._read_buf[got - flen : got] *= fade
|
|
256
|
+
|
|
257
|
+
# 2. Adaptive cushion expansion on repeated underruns (auto-adapts for low-end PCs)
|
|
258
|
+
if now - self._last_underrun_time < 4.0:
|
|
259
|
+
self._consecutive_underruns += 1
|
|
260
|
+
if self._consecutive_underruns >= 2 and self.prefill < self.max_prefill:
|
|
261
|
+
step = int(0.5 * frames)
|
|
262
|
+
self.prefill = min(self.max_prefill, self.prefill + step)
|
|
263
|
+
log.debug("Adaptive buffer expanded to %d samples for '%s'", self.prefill, self.spec.name)
|
|
264
|
+
else:
|
|
265
|
+
self._consecutive_underruns = 1
|
|
266
|
+
self._last_underrun_time = now
|
|
267
|
+
self._stable_blocks = 0
|
|
268
|
+
|
|
269
|
+
# 3. Top up jitter cushion to prevent cascading starvation without discarding valid speech
|
|
270
|
+
missing = self.prefill - self.ring.fill
|
|
271
|
+
if missing > 0:
|
|
272
|
+
self.ring.write(self._silence[:missing])
|
|
273
|
+
self._avg_fill = float(target_fill)
|
|
274
|
+
# Settling cooldown prevents drift tracker from entering hunting oscillations
|
|
275
|
+
self._drift_cooldown = -100
|
|
276
|
+
self._needs_fade_in = True
|
|
277
|
+
else:
|
|
278
|
+
self._stable_blocks += 1
|
|
279
|
+
# Gentle relaxation towards base prefill after sustained stability (3000 blocks ~ 30 s)
|
|
280
|
+
if self._stable_blocks > 3000 and self.prefill > self.base_prefill:
|
|
281
|
+
self.prefill = max(self.base_prefill, self.prefill - 1)
|
|
282
|
+
self._stable_blocks = 2000
|
|
283
|
+
|
|
284
|
+
# Smooth resume fade-in when recovering from an underrun (prevents leading edge click)
|
|
285
|
+
if self._needs_fade_in and got >= want:
|
|
286
|
+
flen = min(got, self._fade_samples)
|
|
287
|
+
fade = np.arange(1, flen + 1, dtype=np.float32) / flen
|
|
288
|
+
self._read_buf[:flen] *= fade
|
|
289
|
+
self._needs_fade_in = False
|
|
290
|
+
|
|
291
|
+
# Write to output: fractional linear interpolation resampling for clock drift
|
|
292
|
+
if want == frames + 1 and got >= frames + 1:
|
|
293
|
+
# Squeeze frames+1 samples into frames output samples via linear interpolation
|
|
294
|
+
src = self._read_buf[:frames + 1]
|
|
295
|
+
indices = np.linspace(0, frames, frames, endpoint=False, dtype=np.float32)
|
|
296
|
+
idx_int = indices.astype(np.int32)
|
|
297
|
+
frac = indices - idx_int
|
|
298
|
+
outdata[:, 0] = src[idx_int] * (1.0 - frac) + src[np.minimum(idx_int + 1, frames)] * frac
|
|
299
|
+
self.corrections += 1
|
|
300
|
+
elif want == frames - 1 and got >= frames - 1:
|
|
301
|
+
# Stretch frames-1 samples into frames output samples via linear interpolation
|
|
302
|
+
src = self._read_buf[:frames - 1]
|
|
303
|
+
indices = np.linspace(0, frames - 2, frames, endpoint=True, dtype=np.float32)
|
|
304
|
+
idx_int = np.minimum(indices.astype(np.int32), frames - 2)
|
|
305
|
+
frac = indices - idx_int
|
|
306
|
+
outdata[:, 0] = src[idx_int] * (1.0 - frac) + src[np.minimum(idx_int + 1, frames - 2)] * frac
|
|
307
|
+
self.corrections += 1
|
|
308
|
+
else:
|
|
309
|
+
outdata[:frames, 0] = self._read_buf[:frames]
|
|
310
|
+
|
|
311
|
+
# Track last output sample for continuity
|
|
312
|
+
self._last_sample = outdata[frames - 1, 0]
|
|
313
|
+
|
|
314
|
+
if self.channels > 1:
|
|
315
|
+
outdata[:, 1:] = outdata[:, :1]
|
|
316
|
+
|
|
317
|
+
# Track last output sample for continuity
|
|
318
|
+
self._last_sample = outdata[frames - 1, 0]
|
|
319
|
+
|
|
320
|
+
if self.channels > 1:
|
|
321
|
+
outdata[:, 1:] = outdata[:, :1]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class AudioEngine:
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
*,
|
|
328
|
+
chain: Chain,
|
|
329
|
+
input_device: DeviceInfo,
|
|
330
|
+
sinks: list[SinkSpec],
|
|
331
|
+
input_channel: int = 0,
|
|
332
|
+
sample_rate: int = 48000,
|
|
333
|
+
block_size: int = 480,
|
|
334
|
+
jitter_blocks: float | int = 1.5,
|
|
335
|
+
play_cues: bool = True,
|
|
336
|
+
cue_volume: float = 0.5,
|
|
337
|
+
enable_loopback: bool = True,
|
|
338
|
+
aec_reference_device: Any = None,
|
|
339
|
+
enable_device_watch: bool = False,
|
|
340
|
+
noise_environment: str = "quiet_room",
|
|
341
|
+
) -> None:
|
|
342
|
+
self.chain = chain
|
|
343
|
+
self.input_device = input_device
|
|
344
|
+
self.input_channel = input_channel
|
|
345
|
+
self.sr = sample_rate
|
|
346
|
+
self.block = block_size
|
|
347
|
+
self.sinks = {s.name: OutputSink(s, sample_rate, block_size, jitter_blocks) for s in sinks}
|
|
348
|
+
self.jitter_blocks = jitter_blocks
|
|
349
|
+
self.cues = CuePlayer(sample_rate=sample_rate, volume=cue_volume, enabled=play_cues)
|
|
350
|
+
self.enable_loopback = enable_loopback
|
|
351
|
+
self.aec_reference_device = aec_reference_device
|
|
352
|
+
self._loopback: LoopbackCapture | None = None
|
|
353
|
+
self.noise_profiler: AdaptiveNoiseProfiler | None = AdaptiveNoiseProfiler(sample_rate=sample_rate, environment=noise_environment)
|
|
354
|
+
self.device_watcher: DeviceWatcher | None = None
|
|
355
|
+
self._recovering = False
|
|
356
|
+
|
|
357
|
+
self.bypass = False
|
|
358
|
+
self.muted = False
|
|
359
|
+
self._in_meter = LevelMeter(sample_rate)
|
|
360
|
+
self._out_meter = LevelMeter(sample_rate)
|
|
361
|
+
self._ref_meter = LevelMeter(sample_rate)
|
|
362
|
+
self._in_spec = SpectrumAnalyzer(sample_rate)
|
|
363
|
+
self._out_spec = SpectrumAnalyzer(sample_rate)
|
|
364
|
+
self._in_stream: Any = None
|
|
365
|
+
self._running = False
|
|
366
|
+
|
|
367
|
+
self._cpu = 0.0
|
|
368
|
+
self._cpu_peak = 0.0
|
|
369
|
+
self._overflows = 0
|
|
370
|
+
self._errors = 0
|
|
371
|
+
self._telemetry: dict[str, dict[str, float]] = {}
|
|
372
|
+
self._tel_count = 0
|
|
373
|
+
|
|
374
|
+
self._cal_need = 0
|
|
375
|
+
self._cal_buf: list[np.ndarray] = []
|
|
376
|
+
self._cal_have = 0
|
|
377
|
+
self._cal_progress: float | None = None
|
|
378
|
+
self._cal_result: CalibrationResult | None = None
|
|
379
|
+
self._lock = threading.Lock()
|
|
380
|
+
|
|
381
|
+
# ----------------------------------------------------------- lifecycle
|
|
382
|
+
def start(self) -> None:
|
|
383
|
+
sd = get_sd()
|
|
384
|
+
dev = self.input_device
|
|
385
|
+
channels = max(1, min(dev.max_input, self.input_channel + 1))
|
|
386
|
+
if self.input_channel >= dev.max_input:
|
|
387
|
+
raise DeviceError(
|
|
388
|
+
f"Input channel {self.input_channel} does not exist on '{dev.name}' ({dev.max_input} channels)."
|
|
389
|
+
)
|
|
390
|
+
self._channels = channels
|
|
391
|
+
try:
|
|
392
|
+
self._in_stream = sd.InputStream(
|
|
393
|
+
device=dev.index,
|
|
394
|
+
samplerate=self.sr,
|
|
395
|
+
blocksize=self.block,
|
|
396
|
+
channels=channels,
|
|
397
|
+
dtype="float32",
|
|
398
|
+
latency="low",
|
|
399
|
+
callback=self._on_input,
|
|
400
|
+
extra_settings=extra_settings(dev, "input"),
|
|
401
|
+
)
|
|
402
|
+
except Exception as exc: # noqa: BLE001
|
|
403
|
+
raise DeviceError(
|
|
404
|
+
f"Cannot open microphone '{dev.name}' at {self.sr} Hz: {exc}",
|
|
405
|
+
hint="Close apps using the mic in exclusive mode, or try sample_rate 44100.",
|
|
406
|
+
) from exc
|
|
407
|
+
opened: list[OutputSink] = []
|
|
408
|
+
try:
|
|
409
|
+
for s in self.sinks.values():
|
|
410
|
+
s.open()
|
|
411
|
+
opened.append(s)
|
|
412
|
+
self._running = True
|
|
413
|
+
self._in_stream.start()
|
|
414
|
+
for s in opened:
|
|
415
|
+
s.start()
|
|
416
|
+
if self.device_watcher is not None:
|
|
417
|
+
self.device_watcher.start()
|
|
418
|
+
# Start loopback capture for AEC if AEC is present and loopback is enabled
|
|
419
|
+
if self.enable_loopback and self.chain.stage("aec") is not None:
|
|
420
|
+
try:
|
|
421
|
+
self._loopback = LoopbackCapture(
|
|
422
|
+
sample_rate=self.sr,
|
|
423
|
+
block_size=self.block,
|
|
424
|
+
callback=self.feed_reference,
|
|
425
|
+
device_spec=self.aec_reference_device,
|
|
426
|
+
)
|
|
427
|
+
self._loopback.start()
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
log.debug("AEC loopback startup note: %s", exc)
|
|
430
|
+
except Exception:
|
|
431
|
+
self._running = False
|
|
432
|
+
for s in opened:
|
|
433
|
+
s.close()
|
|
434
|
+
if self._in_stream is not None:
|
|
435
|
+
self._in_stream.close()
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
def stop(self) -> None:
|
|
439
|
+
self._running = False
|
|
440
|
+
if self.device_watcher is not None:
|
|
441
|
+
try:
|
|
442
|
+
self.device_watcher.stop()
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
if self._loopback is not None:
|
|
446
|
+
try:
|
|
447
|
+
self._loopback.stop()
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
self._loopback = None
|
|
451
|
+
if self._in_stream is not None:
|
|
452
|
+
try:
|
|
453
|
+
self._in_stream.stop()
|
|
454
|
+
self._in_stream.close()
|
|
455
|
+
except Exception: # noqa: BLE001
|
|
456
|
+
log.debug("input close failed", exc_info=True)
|
|
457
|
+
self._in_stream = None
|
|
458
|
+
for s in self.sinks.values():
|
|
459
|
+
s.close()
|
|
460
|
+
|
|
461
|
+
def enable_device_watch(self) -> None:
|
|
462
|
+
"""Enable hot-plug monitoring and auto-recovery."""
|
|
463
|
+
if self.device_watcher is not None:
|
|
464
|
+
return
|
|
465
|
+
self.device_watcher = DeviceWatcher(
|
|
466
|
+
on_recovery_attempt=self._handle_device_recovery,
|
|
467
|
+
on_fallback=lambda: self.set_bypass(True),
|
|
468
|
+
)
|
|
469
|
+
if self._running:
|
|
470
|
+
self.device_watcher.start()
|
|
471
|
+
|
|
472
|
+
def _handle_device_recovery(self, kind: str, target: Any) -> bool:
|
|
473
|
+
self._recovering = True
|
|
474
|
+
try:
|
|
475
|
+
log.info("AudioEngine recovering %s stream...", kind)
|
|
476
|
+
from .devices import find_device, list_devices
|
|
477
|
+
pool = list_devices()
|
|
478
|
+
if kind == "input":
|
|
479
|
+
if self._in_stream is not None:
|
|
480
|
+
try:
|
|
481
|
+
self._in_stream.stop()
|
|
482
|
+
self._in_stream.close()
|
|
483
|
+
except Exception:
|
|
484
|
+
pass
|
|
485
|
+
new_in = None
|
|
486
|
+
if target is not None:
|
|
487
|
+
try:
|
|
488
|
+
new_in = find_device(target, "input", pool)
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
if new_in is None:
|
|
492
|
+
new_in = find_device(None, "input", pool)
|
|
493
|
+
self.input_device = new_in
|
|
494
|
+
channels = max(1, min(new_in.max_input, self.input_channel + 1))
|
|
495
|
+
sd = get_sd()
|
|
496
|
+
self._in_stream = sd.InputStream(
|
|
497
|
+
device=new_in.index,
|
|
498
|
+
samplerate=self.sr,
|
|
499
|
+
blocksize=self.block,
|
|
500
|
+
channels=channels,
|
|
501
|
+
dtype=np.float32,
|
|
502
|
+
callback=self._on_input,
|
|
503
|
+
**extra_settings(new_in),
|
|
504
|
+
)
|
|
505
|
+
self._in_stream.start()
|
|
506
|
+
log.info("AudioEngine input successfully rebound to: %s", new_in.name)
|
|
507
|
+
return True
|
|
508
|
+
else:
|
|
509
|
+
s = self.sinks.get(kind)
|
|
510
|
+
if s is not None:
|
|
511
|
+
try:
|
|
512
|
+
s.close()
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
s.open()
|
|
516
|
+
s.start()
|
|
517
|
+
return True
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
log.warning("Stream recovery failed: %s", exc)
|
|
520
|
+
return False
|
|
521
|
+
finally:
|
|
522
|
+
self._recovering = False
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
def relearn_noise(self, duration_s: float = 1.0) -> None:
|
|
526
|
+
"""Quick trigger to relearn background ambient noise."""
|
|
527
|
+
if self.noise_profiler is not None:
|
|
528
|
+
self.noise_profiler.relearn(duration_s)
|
|
529
|
+
|
|
530
|
+
def switch_noise_environment(self, name: str) -> None:
|
|
531
|
+
"""Switch active noise environment profile."""
|
|
532
|
+
if self.noise_profiler is not None:
|
|
533
|
+
self.noise_profiler.switch_environment(name)
|
|
534
|
+
|
|
535
|
+
def freeze_noise_profile(self) -> None:
|
|
536
|
+
"""Freeze adaptive noise profile learning."""
|
|
537
|
+
if self.noise_profiler is not None:
|
|
538
|
+
self.noise_profiler.freeze()
|
|
539
|
+
|
|
540
|
+
def unfreeze_noise_profile(self) -> None:
|
|
541
|
+
"""Unfreeze adaptive noise profile learning."""
|
|
542
|
+
if self.noise_profiler is not None:
|
|
543
|
+
self.noise_profiler.unfreeze()
|
|
544
|
+
|
|
545
|
+
def feed_reference(self, ref_data: np.ndarray) -> None:
|
|
546
|
+
"""Feed far-end system/speaker audio to AEC and reference level meter."""
|
|
547
|
+
if ref_data is not None and len(ref_data) > 0:
|
|
548
|
+
self._ref_meter.feed(ref_data)
|
|
549
|
+
aec = self.chain.stage("aec")
|
|
550
|
+
if aec is not None and hasattr(aec, "feed_reference"):
|
|
551
|
+
aec.feed_reference(ref_data)
|
|
552
|
+
|
|
553
|
+
def __enter__(self) -> "AudioEngine":
|
|
554
|
+
self.start()
|
|
555
|
+
return self
|
|
556
|
+
|
|
557
|
+
def __exit__(self, *exc: object) -> None:
|
|
558
|
+
self.stop()
|
|
559
|
+
|
|
560
|
+
# ------------------------------------------------------------ controls
|
|
561
|
+
def set_chain(self, chain: Chain) -> None:
|
|
562
|
+
self.chain = chain # atomic swap; old chain is simply dropped
|
|
563
|
+
for s in self.sinks.values():
|
|
564
|
+
s.notify_pipeline_change()
|
|
565
|
+
|
|
566
|
+
def toggle_bypass(self) -> bool:
|
|
567
|
+
return self.set_bypass(not self.bypass)
|
|
568
|
+
|
|
569
|
+
def set_bypass(self, bypass: bool) -> bool:
|
|
570
|
+
if self.bypass != bypass:
|
|
571
|
+
self.bypass = bypass
|
|
572
|
+
self.cues.play("toggle")
|
|
573
|
+
mon = self.sinks.get("monitor")
|
|
574
|
+
if mon is None or not mon.enabled:
|
|
575
|
+
self.cues.play_standalone("toggle")
|
|
576
|
+
return self.bypass
|
|
577
|
+
|
|
578
|
+
def toggle_mute(self) -> bool:
|
|
579
|
+
return self.set_muted(not self.muted)
|
|
580
|
+
|
|
581
|
+
def set_muted(self, muted: bool) -> bool:
|
|
582
|
+
if self.muted != muted:
|
|
583
|
+
self.muted = muted
|
|
584
|
+
cue = "mute" if muted else "unmute"
|
|
585
|
+
self.cues.play(cue)
|
|
586
|
+
mon = self.sinks.get("monitor")
|
|
587
|
+
if mon is None or not mon.enabled:
|
|
588
|
+
self.cues.play_standalone(cue)
|
|
589
|
+
return self.muted
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def monitor_available(self) -> bool:
|
|
593
|
+
return "monitor" in self.sinks
|
|
594
|
+
|
|
595
|
+
def toggle_monitor(self) -> bool:
|
|
596
|
+
s = self.sinks.get("monitor")
|
|
597
|
+
if s is None:
|
|
598
|
+
return False
|
|
599
|
+
s.set_enabled(not s.enabled)
|
|
600
|
+
return s.enabled
|
|
601
|
+
|
|
602
|
+
def begin_calibration(self, seconds: float = 3.0) -> None:
|
|
603
|
+
with self._lock:
|
|
604
|
+
self._cal_need = int(seconds * self.sr)
|
|
605
|
+
self._cal_buf, self._cal_have = [], 0
|
|
606
|
+
self._cal_progress = 0.0
|
|
607
|
+
self._cal_result = None
|
|
608
|
+
|
|
609
|
+
def pop_calibration(self) -> CalibrationResult | None:
|
|
610
|
+
with self._lock:
|
|
611
|
+
res, self._cal_result = self._cal_result, None
|
|
612
|
+
return res
|
|
613
|
+
|
|
614
|
+
# --------------------------------------------------------------- audio
|
|
615
|
+
def _on_input(self, indata: np.ndarray, frames: int, _time: Any, status: Any) -> None:
|
|
616
|
+
t0 = time.perf_counter()
|
|
617
|
+
if status and getattr(status, "input_overflow", False):
|
|
618
|
+
self._overflows += 1
|
|
619
|
+
x = np.ascontiguousarray(indata[:, self.input_channel], dtype=np.float32)
|
|
620
|
+
try:
|
|
621
|
+
self._in_meter.update(x)
|
|
622
|
+
self._in_spec.feed(x)
|
|
623
|
+
self._collect_calibration(x)
|
|
624
|
+
processed = self.chain.process(x)
|
|
625
|
+
if self.noise_profiler is not None:
|
|
626
|
+
gate = self.chain.stage("gate")
|
|
627
|
+
is_voiced = bool(getattr(gate, "is_open", False))
|
|
628
|
+
self.noise_profiler.update(x, is_voiced=is_voiced)
|
|
629
|
+
out = x if self.bypass else processed
|
|
630
|
+
if self.muted:
|
|
631
|
+
out = np.zeros_like(out)
|
|
632
|
+
self._out_meter.update(out)
|
|
633
|
+
self._out_spec.feed(out)
|
|
634
|
+
for name, s in self.sinks.items():
|
|
635
|
+
if name == "monitor":
|
|
636
|
+
s.push(self.cues.mix_into(out))
|
|
637
|
+
else:
|
|
638
|
+
s.push(out)
|
|
639
|
+
self._tel_count = (self._tel_count + 1) % 4
|
|
640
|
+
if self._tel_count == 0:
|
|
641
|
+
self._telemetry = self.chain.telemetry()
|
|
642
|
+
except Exception: # noqa: BLE001 - never kill the audio thread; fall back to pass-through
|
|
643
|
+
self._errors += 1
|
|
644
|
+
log.debug("DSP failure; passing audio through", exc_info=True)
|
|
645
|
+
# Mute on error instead of switching to raw audio, which causes a
|
|
646
|
+
# spectral/gain discontinuity heard as a loud click. Missing 10 ms
|
|
647
|
+
# of silence is far less objectionable than a pop.
|
|
648
|
+
silence = np.zeros_like(x)
|
|
649
|
+
for name, s in self.sinks.items():
|
|
650
|
+
if name == "monitor":
|
|
651
|
+
s.push(self.cues.mix_into(silence))
|
|
652
|
+
else:
|
|
653
|
+
s.push(silence)
|
|
654
|
+
load = (time.perf_counter() - t0) / (frames / self.sr)
|
|
655
|
+
self._cpu = 0.9 * self._cpu + 0.1 * load
|
|
656
|
+
self._cpu_peak = max(load, self._cpu_peak * 0.995)
|
|
657
|
+
|
|
658
|
+
def _collect_calibration(self, x: np.ndarray) -> None:
|
|
659
|
+
if self._cal_progress is None or self._cal_need == 0:
|
|
660
|
+
return
|
|
661
|
+
self._cal_buf.append(x.copy())
|
|
662
|
+
self._cal_have += x.shape[0]
|
|
663
|
+
self._cal_progress = min(1.0, self._cal_have / self._cal_need)
|
|
664
|
+
if self._cal_have >= self._cal_need:
|
|
665
|
+
noise = np.concatenate(self._cal_buf)
|
|
666
|
+
self._cal_need = 0
|
|
667
|
+
self._cal_buf = []
|
|
668
|
+
threading.Thread(target=self._finish_calibration, args=(noise,), daemon=True).start()
|
|
669
|
+
|
|
670
|
+
def _finish_calibration(self, noise: np.ndarray) -> None:
|
|
671
|
+
try:
|
|
672
|
+
chain = self.chain
|
|
673
|
+
dn = chain.stage("denoise")
|
|
674
|
+
psd = dn.learn(noise) if hasattr(dn, "learn") else None
|
|
675
|
+
noise_db = rms_db(noise)
|
|
676
|
+
threshold = noise_db + chain.input_gain_db + 9.0
|
|
677
|
+
gate = chain.stage("gate")
|
|
678
|
+
if gate is not None:
|
|
679
|
+
gate.threshold_db = threshold # type: ignore[attr-defined]
|
|
680
|
+
n_fft = getattr(dn, "n_fft", 0)
|
|
681
|
+
result = CalibrationResult(psd, noise_db, threshold, n_fft, self.sr)
|
|
682
|
+
with self._lock:
|
|
683
|
+
self._cal_result = result
|
|
684
|
+
except Exception: # noqa: BLE001
|
|
685
|
+
self._errors += 1
|
|
686
|
+
log.exception("calibration failed")
|
|
687
|
+
finally:
|
|
688
|
+
self._cal_progress = None
|
|
689
|
+
|
|
690
|
+
# ------------------------------------------------------------ snapshot
|
|
691
|
+
def latency_ms(self) -> float:
|
|
692
|
+
# Realistic hardware I/O latency: one hardware block period each for ADC capture
|
|
693
|
+
# and DAC output, bounded by the driver-reported buffer.
|
|
694
|
+
hw_in = self.block / self.sr
|
|
695
|
+
if self._in_stream is not None:
|
|
696
|
+
try:
|
|
697
|
+
# Operational delay is ~half of PortAudio's double-buffer capacity
|
|
698
|
+
hw_in = min(float(self._in_stream.latency) * 0.5, hw_in * 1.5)
|
|
699
|
+
except Exception: # noqa: BLE001
|
|
700
|
+
pass
|
|
701
|
+
hw_out = self.block / self.sr
|
|
702
|
+
if self.sinks:
|
|
703
|
+
try:
|
|
704
|
+
max_out = max(s.latency_s for s in self.sinks.values())
|
|
705
|
+
hw_out = min(max_out * 0.5, hw_out * 1.5)
|
|
706
|
+
except Exception: # noqa: BLE001
|
|
707
|
+
pass
|
|
708
|
+
buf = float(self.jitter_blocks) * self.block / self.sr
|
|
709
|
+
dsp = self.chain.latency_samples / self.sr
|
|
710
|
+
return (hw_in + hw_out + buf + dsp) * 1000.0
|
|
711
|
+
|
|
712
|
+
def snapshot(self) -> EngineSnapshot:
|
|
713
|
+
mon = self.sinks.get("monitor")
|
|
714
|
+
return EngineSnapshot(
|
|
715
|
+
running=self._running,
|
|
716
|
+
in_level=self._in_meter.snapshot,
|
|
717
|
+
out_level=self._out_meter.snapshot,
|
|
718
|
+
in_bands=self._in_spec.bands.copy(),
|
|
719
|
+
out_bands=self._out_spec.bands.copy(),
|
|
720
|
+
telemetry=dict(self._telemetry),
|
|
721
|
+
cpu_load=self._cpu,
|
|
722
|
+
cpu_peak=self._cpu_peak,
|
|
723
|
+
input_overflows=self._overflows,
|
|
724
|
+
output_underruns=sum(s.underruns for s in self.sinks.values() if s.enabled),
|
|
725
|
+
drift_corrections=sum(s.corrections for s in self.sinks.values()),
|
|
726
|
+
errors=self._errors,
|
|
727
|
+
latency_ms=self.latency_ms(),
|
|
728
|
+
bypass=self.bypass,
|
|
729
|
+
muted=self.muted,
|
|
730
|
+
monitor_on=bool(mon and mon.enabled),
|
|
731
|
+
monitor_available=mon is not None,
|
|
732
|
+
calibrating=self._cal_progress,
|
|
733
|
+
input_gain_db=self.chain.input_gain_db,
|
|
734
|
+
sinks={n: s.enabled for n, s in self.sinks.items()},
|
|
735
|
+
ref_level=self._ref_meter.snapshot if (self._loopback is not None and self._loopback._running) else None,
|
|
736
|
+
noise_profile=self.noise_profiler.current_profile if self.noise_profiler is not None else None,
|
|
737
|
+
recovering=self._recovering,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
@property
|
|
741
|
+
def spectrum_centers(self) -> np.ndarray:
|
|
742
|
+
return self._in_spec.band_centers()
|
|
743
|
+
|
|
744
|
+
@property
|
|
745
|
+
def spectrum_range(self) -> tuple[float, float]:
|
|
746
|
+
return self._in_spec.f_lo, self._in_spec.f_hi
|