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,250 @@
1
+ """Adaptive noise profiling: continuous voice-aware learning, environment profiles, and safe bounds."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import math
7
+ import time
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from ..config import config_dir
15
+
16
+ log = logging.getLogger("micforge.noise_profile")
17
+
18
+ ENVIRONMENTS_DIR_NAME = "noise_profiles"
19
+
20
+
21
+ def _profiles_dir() -> Path:
22
+ p = config_dir() / ENVIRONMENTS_DIR_NAME
23
+ try:
24
+ p.mkdir(parents=True, exist_ok=True)
25
+ except Exception:
26
+ pass
27
+ return p
28
+
29
+
30
+ @dataclass
31
+ class NoiseProfile:
32
+ """Snapshot of ambient noise characteristics."""
33
+ name: str = "quiet_room"
34
+ noise_floor_db: float = -62.0
35
+ gate_threshold_db: float = -50.0
36
+ spectral_tilt: float = 0.0 # ratio of high vs low band energy
37
+ sample_rate: int = 48000
38
+ bands: list[float] = field(default_factory=lambda: [-65.0] * 32)
39
+ created_at: float = field(default_factory=time.time)
40
+ updated_at: float = field(default_factory=time.time)
41
+ sample_count: int = 0
42
+ frozen: bool = False
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return asdict(self)
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: dict[str, Any]) -> NoiseProfile:
49
+ return cls(
50
+ name=str(data.get("name", "custom")),
51
+ noise_floor_db=float(data.get("noise_floor_db", -62.0)),
52
+ gate_threshold_db=float(data.get("gate_threshold_db", -50.0)),
53
+ spectral_tilt=float(data.get("spectral_tilt", 0.0)),
54
+ sample_rate=int(data.get("sample_rate", 48000)),
55
+ bands=list(data.get("bands", [-65.0] * 32)),
56
+ created_at=float(data.get("created_at", time.time())),
57
+ updated_at=float(data.get("updated_at", time.time())),
58
+ sample_count=int(data.get("sample_count", 0)),
59
+ frozen=bool(data.get("frozen", False)),
60
+ )
61
+
62
+
63
+ # Standard environment baseline presets
64
+ BUILTIN_ENVIRONMENTS: dict[str, NoiseProfile] = {
65
+ "quiet_room": NoiseProfile(
66
+ name="quiet_room",
67
+ noise_floor_db=-64.0,
68
+ gate_threshold_db=-52.0,
69
+ spectral_tilt=-0.3,
70
+ bands=[-65.0] * 32,
71
+ ),
72
+ "fan_on": NoiseProfile(
73
+ name="fan_on",
74
+ noise_floor_db=-52.0,
75
+ gate_threshold_db=-42.0,
76
+ spectral_tilt=-0.8, # strong low-frequency motor rumble
77
+ bands=[-50.0 if i < 8 else -56.0 for i in range(32)],
78
+ ),
79
+ "mechanical_keyboard": NoiseProfile(
80
+ name="mechanical_keyboard",
81
+ noise_floor_db=-56.0,
82
+ gate_threshold_db=-38.0,
83
+ spectral_tilt=0.4, # transient click presence
84
+ bands=[-58.0 if i < 16 else -52.0 for i in range(32)],
85
+ ),
86
+ }
87
+
88
+
89
+ class AdaptiveNoiseProfiler:
90
+ """Continuous low-rate, voice-aware background noise learner."""
91
+
92
+ def __init__(
93
+ self,
94
+ sample_rate: int = 48000,
95
+ environment: str = "quiet_room",
96
+ adaptation_rate: float = 0.005, # smooth update ~3-5s time constant
97
+ max_rate_change_db_per_sec: float = 1.0,
98
+ ) -> None:
99
+ self.sample_rate = sample_rate
100
+ self.adaptation_rate = adaptation_rate
101
+ self.max_rate_change_db_per_sec = max_rate_change_db_per_sec
102
+ self.current_profile: NoiseProfile = self._load_or_create(environment)
103
+
104
+ self._n_bands = 32
105
+ self._band_powers = np.full(self._n_bands, 10.0 ** (self.current_profile.noise_floor_db / 10.0), dtype=np.float64)
106
+ self._last_update_time = time.monotonic()
107
+ self._consecutive_quiet_blocks = 0
108
+ self._learning_enabled = True
109
+
110
+ def _load_or_create(self, name: str) -> NoiseProfile:
111
+ path = _profiles_dir() / f"{name}.json"
112
+ if path.is_file():
113
+ try:
114
+ data = json.loads(path.read_text(encoding="utf-8"))
115
+ prof = NoiseProfile.from_dict(data)
116
+ prof.sample_rate = self.sample_rate
117
+ return prof
118
+ except Exception as exc:
119
+ log.warning("Could not read profile %s: %s", path, exc)
120
+
121
+ if name in BUILTIN_ENVIRONMENTS:
122
+ base = BUILTIN_ENVIRONMENTS[name]
123
+ return NoiseProfile(
124
+ name=base.name,
125
+ noise_floor_db=base.noise_floor_db,
126
+ gate_threshold_db=base.gate_threshold_db,
127
+ spectral_tilt=base.spectral_tilt,
128
+ sample_rate=self.sample_rate,
129
+ bands=list(base.bands),
130
+ )
131
+ return NoiseProfile(name=name, sample_rate=self.sample_rate)
132
+
133
+ @property
134
+ def is_frozen(self) -> bool:
135
+ return self.current_profile.frozen or not self._learning_enabled
136
+
137
+ def freeze(self) -> None:
138
+ """Freeze adaptation so noise profile remains static."""
139
+ self.current_profile.frozen = True
140
+
141
+ def unfreeze(self) -> None:
142
+ """Resume continuous background learning."""
143
+ self.current_profile.frozen = False
144
+ self._learning_enabled = True
145
+
146
+ def update(
147
+ self,
148
+ block: np.ndarray,
149
+ is_voiced: bool = False,
150
+ speech_prob: float = 0.0,
151
+ ) -> bool:
152
+ """Feed audio block. Returns True if adaptation took place."""
153
+ if self.is_frozen:
154
+ return False
155
+
156
+ if block is None or len(block) == 0:
157
+ return False
158
+
159
+ # SAFE ADAPTATION RULE 1: Never learn during voiced speech or high speech probability
160
+ if is_voiced or speech_prob > 0.25:
161
+ self._consecutive_quiet_blocks = 0
162
+ return False
163
+
164
+ # Instantaneous RMS
165
+ sq_mean = float(np.mean(block * block))
166
+ if sq_mean < 1e-12:
167
+ return False
168
+ rms_db = 10.0 * math.log10(sq_mean)
169
+
170
+ # SAFE ADAPTATION RULE 2: Don't learn transient spikes (>16 dB above current noise floor)
171
+ cur_floor = self.current_profile.noise_floor_db
172
+ if rms_db > cur_floor + 16.0:
173
+ self._consecutive_quiet_blocks = 0
174
+ return False
175
+
176
+ self._consecutive_quiet_blocks += 1
177
+ # Require at least 3 consecutive quiet blocks before updating (prevents speech syllable bleed)
178
+ if self._consecutive_quiet_blocks < 3:
179
+ return False
180
+
181
+ now = time.monotonic()
182
+ dt = max(0.001, now - self._last_update_time)
183
+ self._last_update_time = now
184
+
185
+ # SAFE ADAPTATION RULE 3: Bounded rate of change per second
186
+ target_db = rms_db
187
+ max_delta = self.max_rate_change_db_per_sec * dt
188
+ diff = target_db - cur_floor
189
+ clipped_diff = max(-max_delta, min(max_delta, diff))
190
+ new_floor = cur_floor + self.adaptation_rate * diff
191
+
192
+ # Bound overall noise floor within physically reasonable microphone ranges
193
+ new_floor = max(-85.0, min(-35.0, new_floor))
194
+ self.current_profile.noise_floor_db = float(new_floor)
195
+
196
+ # Gate threshold tracks noise floor with 10 dB headroom (clamped between -60 and -30 dBFS)
197
+ self.current_profile.gate_threshold_db = float(max(-60.0, min(-30.0, new_floor + 10.0)))
198
+ self.current_profile.sample_count += 1
199
+ self.current_profile.updated_at = time.time()
200
+ return True
201
+
202
+ def relearn(self, duration_s: float = 1.0) -> None:
203
+ """Prepare for quick immediate noise capture (e.g. user requested 'relearn')."""
204
+ self._consecutive_quiet_blocks = 5
205
+ self.current_profile.sample_count = 0
206
+ self.unfreeze()
207
+
208
+ def switch_environment(self, name: str) -> NoiseProfile:
209
+ """Switch active noise profile to a named environment."""
210
+ self.current_profile = self._load_or_create(name)
211
+ self._last_update_time = time.monotonic()
212
+ self._consecutive_quiet_blocks = 0
213
+ return self.current_profile
214
+
215
+ def save(self, name: str | None = None) -> Path | None:
216
+ """Persist current noise profile to disk."""
217
+ target_name = name or self.current_profile.name
218
+ self.current_profile.name = target_name
219
+ self.current_profile.updated_at = time.time()
220
+ try:
221
+ d = _profiles_dir()
222
+ path = d / f"{target_name}.json"
223
+ path.write_text(json.dumps(self.current_profile.to_dict(), indent=2), encoding="utf-8")
224
+ log.info("Saved noise profile '%s' to %s", target_name, path)
225
+ return path
226
+ except Exception as exc:
227
+ log.warning("Could not persist noise profile '%s': %s", target_name, exc)
228
+ return None
229
+
230
+ def reset_to_default(self) -> None:
231
+ """Reset current profile to baseline preset values."""
232
+ name = self.current_profile.name
233
+ if name in BUILTIN_ENVIRONMENTS:
234
+ base = BUILTIN_ENVIRONMENTS[name]
235
+ self.current_profile.noise_floor_db = base.noise_floor_db
236
+ self.current_profile.gate_threshold_db = base.gate_threshold_db
237
+ self.current_profile.spectral_tilt = base.spectral_tilt
238
+ else:
239
+ self.current_profile.noise_floor_db = -62.0
240
+ self.current_profile.gate_threshold_db = -50.0
241
+ self.current_profile.updated_at = time.time()
242
+
243
+
244
+ def list_noise_profiles() -> list[str]:
245
+ """List all available environment noise profile names."""
246
+ names = set(BUILTIN_ENVIRONMENTS.keys())
247
+ d = _profiles_dir()
248
+ for f in d.glob("*.json"):
249
+ names.add(f.stem)
250
+ return sorted(names)
@@ -0,0 +1,100 @@
1
+ """Simple microphone recorder + file-style player used by `test` and `calibrate`."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ from typing import Any, Callable
6
+
7
+ import numpy as np
8
+
9
+ from ..errors import DeviceError
10
+ from .backend import get_sd
11
+ from .devices import DeviceInfo, extra_settings
12
+ from .metrics import LevelMeter, LevelSnapshot
13
+
14
+
15
+ class Recorder:
16
+ def __init__(self, device: DeviceInfo, sample_rate: int, channel: int = 0, block: int = 480) -> None:
17
+ self.device, self.sr, self.channel, self.block = device, sample_rate, channel, block
18
+ self.meter = LevelMeter(sample_rate)
19
+ self._chunks: list[np.ndarray] = []
20
+ self._n = 0
21
+ self._stream: Any = None
22
+
23
+ def start(self) -> None:
24
+ sd = get_sd()
25
+ try:
26
+ self._stream = sd.InputStream(
27
+ device=self.device.index,
28
+ samplerate=self.sr,
29
+ blocksize=self.block,
30
+ channels=max(1, min(self.device.max_input, self.channel + 1)),
31
+ dtype="float32",
32
+ callback=self._cb,
33
+ extra_settings=extra_settings(self.device, "input"),
34
+ )
35
+ self._stream.start()
36
+ except Exception as exc: # noqa: BLE001
37
+ raise DeviceError(f"Cannot open microphone '{self.device.name}': {exc}") from exc
38
+
39
+ def _cb(self, indata: np.ndarray, frames: int, _t: Any, _s: Any) -> None:
40
+ x = indata[:, self.channel].copy()
41
+ self._chunks.append(x)
42
+ self._n += frames
43
+ self.meter.update(x)
44
+
45
+ @property
46
+ def seconds(self) -> float:
47
+ return self._n / self.sr
48
+
49
+ @property
50
+ def level(self) -> LevelSnapshot:
51
+ return self.meter.snapshot
52
+
53
+ def stop(self) -> np.ndarray:
54
+ if self._stream is not None:
55
+ self._stream.stop()
56
+ self._stream.close()
57
+ self._stream = None
58
+ return np.concatenate(self._chunks) if self._chunks else np.zeros(0, dtype=np.float32)
59
+
60
+
61
+ def play(
62
+ x: np.ndarray,
63
+ sample_rate: int,
64
+ device: DeviceInfo,
65
+ on_tick: Callable[[float], None] | None = None,
66
+ ) -> None:
67
+ """Blocking playback. Ctrl+C stops it cleanly."""
68
+ sd = get_sd()
69
+ data = np.ascontiguousarray(x, dtype=np.float32)
70
+ channels = max(1, min(2, device.max_output))
71
+ pos = [0]
72
+ done = threading.Event()
73
+
74
+ def cb(outdata: np.ndarray, frames: int, _t: Any, _s: Any) -> None:
75
+ chunk = data[pos[0] : pos[0] + frames]
76
+ outdata[: chunk.shape[0]] = chunk[:, None]
77
+ if chunk.shape[0] < frames:
78
+ outdata[chunk.shape[0] :] = 0
79
+ raise sd.CallbackStop
80
+ pos[0] += frames
81
+
82
+ try:
83
+ stream = sd.OutputStream(
84
+ device=device.index,
85
+ samplerate=sample_rate,
86
+ channels=channels,
87
+ dtype="float32",
88
+ callback=cb,
89
+ finished_callback=done.set,
90
+ extra_settings=extra_settings(device, "output"),
91
+ )
92
+ except Exception as exc: # noqa: BLE001
93
+ raise DeviceError(f"Cannot open output '{device.name}': {exc}") from exc
94
+ with stream:
95
+ try:
96
+ while not done.wait(0.05):
97
+ if on_tick:
98
+ on_tick(pos[0] / sample_rate)
99
+ except KeyboardInterrupt:
100
+ pass
micforge/audio/ring.py ADDED
@@ -0,0 +1,89 @@
1
+ """Thread-safe float32 ring buffer used to hand audio from the input callback to output callbacks.
2
+
3
+ Lock-free Single-Producer Single-Consumer (SPSC) design:
4
+ The write side (input callback) owns `_w`, and the read side (output callback) owns `_r`.
5
+ Both pointers increase monotonically without wrap-around (Python arbitrary-precision integers
6
+ never overflow 32-bit or 64-bit bounds). Fill level is computed directly as `_w - _r`,
7
+ completely eliminating shared mutable counters (`_n`) and cross-thread GIL race conditions
8
+ which previously caused false buffer starvation and runaway underrun cascades.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+
15
+ class RingBuffer:
16
+ def __init__(self, capacity: int) -> None:
17
+ self._cap = int(capacity)
18
+ self._buf = np.zeros(self._cap, dtype=np.float32)
19
+ self._r = 0
20
+ self._w = 0
21
+
22
+ @property
23
+ def fill(self) -> int:
24
+ return max(0, self._w - self._r)
25
+
26
+ def clear(self) -> None:
27
+ self._r = self._w
28
+
29
+ def write(self, x: np.ndarray) -> int:
30
+ """Append samples. Returns how many *old* samples were dropped to make room."""
31
+ x = np.asarray(x, dtype=np.float32)
32
+ if x.shape[0] > self._cap:
33
+ x = x[-self._cap :]
34
+ n = x.shape[0]
35
+ w = self._w
36
+ r = self._r
37
+
38
+ # Overflow: drop oldest samples to make room
39
+ dropped = max(0, (w + n) - r - self._cap)
40
+ if dropped:
41
+ self._r = r + dropped
42
+
43
+ # Copy data into the circular buffer
44
+ w_idx = w % self._cap
45
+ first = min(n, self._cap - w_idx)
46
+ self._buf[w_idx : w_idx + first] = x[:first]
47
+ if first < n:
48
+ self._buf[: n - first] = x[first:]
49
+ self._w = w + n
50
+ return dropped
51
+
52
+ def read(self, n: int) -> tuple[np.ndarray, int]:
53
+ """Read up to n samples. Returns (array of length n, zero-padded; samples actually read)."""
54
+ out = np.zeros(n, dtype=np.float32)
55
+ w = self._w
56
+ r = self._r
57
+ avail = max(0, w - r)
58
+ got = min(n, avail)
59
+ if got > 0:
60
+ r_idx = r % self._cap
61
+ first = min(got, self._cap - r_idx)
62
+ out[:first] = self._buf[r_idx : r_idx + first]
63
+ if first < got:
64
+ out[first:got] = self._buf[: got - first]
65
+ self._r = r + got
66
+ return out, got
67
+
68
+ def read_into(self, dest: np.ndarray) -> int:
69
+ """Read up to len(dest) samples directly into a pre-allocated buffer.
70
+
71
+ This avoids allocating a new array on every output callback – critical
72
+ for real-time audio threads where allocation → GC → click.
73
+ Returns the number of samples actually read; unread portion of dest is zeroed.
74
+ """
75
+ n = dest.shape[0]
76
+ w = self._w
77
+ r = self._r
78
+ avail = max(0, w - r)
79
+ got = min(n, avail)
80
+ if got > 0:
81
+ r_idx = r % self._cap
82
+ first = min(got, self._cap - r_idx)
83
+ dest[:first] = self._buf[r_idx : r_idx + first]
84
+ if first < got:
85
+ dest[first:got] = self._buf[: got - first]
86
+ self._r = r + got
87
+ if got < n:
88
+ dest[got:] = 0.0
89
+ return got