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
@@ -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