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,45 @@
1
+ """WAV reading/writing with mono down-mix and resampling."""
2
+ from __future__ import annotations
3
+
4
+ from math import gcd
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from scipy.io import wavfile
9
+ from scipy.signal import resample_poly
10
+
11
+ from ..errors import MicforgeError
12
+
13
+
14
+ def resample(x: np.ndarray, sr_in: int, sr_out: int) -> np.ndarray:
15
+ if sr_in == sr_out:
16
+ return x
17
+ g = gcd(sr_in, sr_out)
18
+ return resample_poly(x, sr_out // g, sr_in // g).astype(np.float32)
19
+
20
+
21
+ def read_wav(path: str | Path, target_rate: int | None = None) -> tuple[np.ndarray, int]:
22
+ try:
23
+ sr, data = wavfile.read(str(path))
24
+ except (OSError, ValueError) as exc:
25
+ raise MicforgeError(f"Cannot read {path}: {exc}", hint="Only PCM/float WAV files are supported (convert with ffmpeg).") from exc
26
+ if np.issubdtype(data.dtype, np.integer):
27
+ scale = float(np.iinfo(data.dtype).max) + 1.0
28
+ if data.dtype == np.uint8:
29
+ data = data.astype(np.float32) - 128.0
30
+ scale = 128.0
31
+ x = data.astype(np.float32) / scale
32
+ else:
33
+ x = data.astype(np.float32)
34
+ if x.ndim == 2:
35
+ x = x.mean(axis=1)
36
+ if target_rate and target_rate != sr:
37
+ x = resample(x, sr, target_rate)
38
+ sr = target_rate
39
+ return np.ascontiguousarray(x, dtype=np.float32), int(sr)
40
+
41
+
42
+ def write_wav(path: str | Path, x: np.ndarray, sr: int) -> None:
43
+ pcm = (np.clip(x, -1.0, 1.0) * 32767.0).astype(np.int16)
44
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
45
+ wavfile.write(str(path), sr, pcm)
@@ -0,0 +1,169 @@
1
+ """End-to-end latency measurement primitives: loopback chirp cross-correlation, clap detection, and stage breakdown."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import math
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from .backend import get_sd
13
+ from .devices import DeviceInfo, extra_settings, find_device, list_devices
14
+
15
+ log = logging.getLogger("micforge.latency")
16
+
17
+
18
+ @dataclass
19
+ class LatencyBreakdown:
20
+ """Estimated per-stage latency breakdown in milliseconds."""
21
+ input_hardware_ms: float = 10.0
22
+ dsp_pipeline_ms: float = 1.5
23
+ jitter_cushion_ms: float = 15.0
24
+ output_hardware_ms: float = 10.0
25
+
26
+ @property
27
+ def total_estimated_ms(self) -> float:
28
+ return self.input_hardware_ms + self.dsp_pipeline_ms + self.jitter_cushion_ms + self.output_hardware_ms
29
+
30
+
31
+ @dataclass
32
+ class LatencyResult:
33
+ """Outcome of an end-to-end latency benchmark."""
34
+ roundtrip_ms: float
35
+ one_way_ms: float
36
+ confidence_score: float # 0.0 to 1.0
37
+ method: str # "loopback_chirp" | "transient_clap" | "stage_estimate"
38
+ breakdown: LatencyBreakdown = field(default_factory=LatencyBreakdown)
39
+ recommendations: list[str] = field(default_factory=list)
40
+
41
+
42
+ def generate_probe_chirp(sample_rate: int = 48000, duration_s: float = 0.06) -> np.ndarray:
43
+ """Generate a clean band-limited logarithmic chirp (800 Hz -> 4000 Hz) with windowing."""
44
+ n_samples = int(duration_s * sample_rate)
45
+ t = np.linspace(0, duration_s, n_samples, endpoint=False, dtype=np.float32)
46
+ f0, f1 = 800.0, 4000.0
47
+ # Logarithmic chirp instantaneous phase
48
+ phase = 2.0 * np.pi * f0 * ((f1 / f0) ** (t / duration_s) - 1.0) / math.log(f1 / f0)
49
+ chirp = 0.4 * np.sin(phase).astype(np.float32)
50
+ # Cosine taper edges (Hanning window) to prevent click artifacts
51
+ window = np.hanning(n_samples).astype(np.float32)
52
+ return chirp * window
53
+
54
+
55
+ def compute_cross_correlation_delay(
56
+ reference: np.ndarray,
57
+ recorded: np.ndarray,
58
+ sample_rate: int = 48000,
59
+ ) -> tuple[float, float]:
60
+ """Compute delay in seconds and confidence (0.0-1.0) using normalized cross-correlation."""
61
+ if len(recorded) < len(reference) or len(reference) == 0:
62
+ return 0.0, 0.0
63
+
64
+ # Fast normalized cross-correlation via FFT
65
+ ref_norm = reference / (np.linalg.norm(reference) + 1e-12)
66
+ rec_norm = recorded / (np.linalg.norm(recorded) + 1e-12)
67
+
68
+ corr = np.correlate(rec_norm, ref_norm, mode="valid")
69
+ abs_corr = np.abs(corr)
70
+ peak_idx = int(np.argmax(abs_corr))
71
+ peak_val = float(abs_corr[peak_idx])
72
+
73
+ # Calculate Peak-to-Sidelobe Ratio (PSLR) for confidence
74
+ # Exclude peak neighborhood (+- 20 samples)
75
+ sidelobe_mask = np.ones_like(abs_corr, dtype=bool)
76
+ lo = max(0, peak_idx - 25)
77
+ hi = min(len(abs_corr), peak_idx + 25)
78
+ sidelobe_mask[lo:hi] = False
79
+
80
+ sidelobes = abs_corr[sidelobe_mask]
81
+ if len(sidelobes) > 0 and sidelobes.max() > 1e-9:
82
+ pslr_db = 20.0 * math.log10(peak_val / (sidelobes.max() + 1e-12))
83
+ confidence = float(np.clip((pslr_db - 3.0) / 15.0, 0.0, 1.0))
84
+ else:
85
+ confidence = 0.8 if peak_val > 0.3 else 0.2
86
+
87
+ delay_s = peak_idx / sample_rate
88
+ return delay_s, confidence
89
+
90
+
91
+ def measure_end_to_end_latency(
92
+ input_device: DeviceInfo | int | str | None = None,
93
+ output_device: DeviceInfo | int | str | None = None,
94
+ sample_rate: int = 48000,
95
+ block_size: int = 480,
96
+ jitter_blocks: float = 1.5,
97
+ ) -> LatencyResult:
98
+ """Measure actual round-trip latency by playing a probe chirp and recording return signal."""
99
+ sd = get_sd()
100
+ pool = list_devices(all_apis=True)
101
+ in_dev = find_device(input_device, "input", pool)
102
+ out_dev = find_device(output_device, "output", pool)
103
+
104
+ probe = generate_probe_chirp(sample_rate, duration_s=0.06)
105
+ silence_pre = np.zeros(int(0.15 * sample_rate), dtype=np.float32)
106
+ silence_post = np.zeros(int(0.60 * sample_rate), dtype=np.float32)
107
+ play_buf = np.concatenate([silence_pre, probe, silence_post])
108
+
109
+ rec_buf = np.zeros(len(play_buf), dtype=np.float32)
110
+ rec_ptr = 0
111
+ play_ptr = 0
112
+ done_event = time.time() + 1.2
113
+
114
+ # If both devices are valid and sounddevice allows playrec:
115
+ try:
116
+ # Simultaneous play and record
117
+ out_data = play_buf[:, None] if out_dev.max_output == 1 else np.repeat(play_buf[:, None], min(2, out_dev.max_output), axis=1)
118
+ rec_raw = sd.playrec(
119
+ out_data,
120
+ samplerate=sample_rate,
121
+ channels=1,
122
+ input_device=in_dev.index,
123
+ output_device=out_dev.index,
124
+ dtype=np.float32,
125
+ )
126
+ sd.wait()
127
+ rec_signal = rec_raw[:, 0]
128
+ delay_s, conf = compute_cross_correlation_delay(probe, rec_signal, sample_rate)
129
+ # Account for pre-silence offset
130
+ measured_delay = max(0.005, delay_s - 0.15)
131
+ roundtrip_ms = measured_delay * 1000.0
132
+ one_way_ms = roundtrip_ms / 2.0
133
+ method = "loopback_chirp"
134
+ except Exception as exc:
135
+ log.warning("Hardware loopback test failed (%s); estimating from pipeline parameters", exc)
136
+ # Synthetic mathematical breakdown fallback
137
+ roundtrip_ms = (block_size / sample_rate * 2 + jitter_blocks * block_size / sample_rate + 0.003) * 1000.0 * 2.0
138
+ one_way_ms = roundtrip_ms / 2.0
139
+ conf = 0.5
140
+ method = "stage_estimate"
141
+
142
+ # Build Stage Breakdown
143
+ breakdown = LatencyBreakdown(
144
+ input_hardware_ms=(block_size / sample_rate) * 1000.0,
145
+ dsp_pipeline_ms=1.8,
146
+ jitter_cushion_ms=(jitter_blocks * block_size / sample_rate) * 1000.0,
147
+ output_hardware_ms=(block_size / sample_rate) * 1000.0,
148
+ )
149
+
150
+ # Actionable guidance
151
+ recs = []
152
+ if one_way_ms > 90.0:
153
+ recs.append("Latency is high (>90ms). In config.json, lower block_size from 480 to 256 samples.")
154
+ recs.append("On Windows, ensure your audio device is using WASAPI instead of high-latency MME.")
155
+ elif one_way_ms > 50.0:
156
+ recs.append("Latency is acceptable (~50-90ms) for Discord voice chat and casual gaming.")
157
+ recs.append("For competitive esports, try jitter_blocks: 1.0 for ~15ms extra reduction.")
158
+ else:
159
+ recs.append("Ultra-low latency (<50ms)! Excellent esports and live broadcast performance.")
160
+
161
+ return LatencyResult(
162
+ roundtrip_ms=roundtrip_ms,
163
+ one_way_ms=one_way_ms,
164
+ confidence_score=conf,
165
+ method=method,
166
+ breakdown=breakdown,
167
+ recommendations=recs,
168
+ )
169
+
@@ -0,0 +1,113 @@
1
+ """Loopback capture for Acoustic Echo Cancellation (AEC).
2
+
3
+ Captures default speaker / headphone output audio in memory and streams it
4
+ to the AEC reference buffer for real-time acoustic echo removal.
5
+
6
+ On Windows: uses native Windows WASAPI Loopback (zero-cable, in-memory).
7
+ On macOS / Linux: binds to default monitor or virtual output endpoints.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Any, Callable
13
+
14
+ import numpy as np
15
+
16
+ from .backend import get_sd
17
+ from .devices import DeviceInfo
18
+
19
+ log = logging.getLogger("micforge.loopback")
20
+
21
+
22
+ class LoopbackCapture:
23
+ """Background stream that feeds speaker playback audio into AEC."""
24
+
25
+ def __init__(
26
+ self,
27
+ sample_rate: int = 48000,
28
+ block_size: int = 480,
29
+ callback: Callable[[np.ndarray], None] | None = None,
30
+ device_spec: Any = None,
31
+ ) -> None:
32
+ self.sr = sample_rate
33
+ self.block = block_size
34
+ self.callback = callback
35
+ self.device_spec = device_spec
36
+ self.stream: Any = None
37
+ self._running = False
38
+
39
+ def start(self) -> bool:
40
+ """Start capturing speaker loopback audio."""
41
+ sd = get_sd()
42
+ try:
43
+ extra = None
44
+ dev_idx = None
45
+
46
+ dev_idx = None
47
+
48
+ if self.device_spec is not None:
49
+ if isinstance(self.device_spec, DeviceInfo):
50
+ dev_idx = self.device_spec.index
51
+ elif isinstance(self.device_spec, int):
52
+ dev_idx = self.device_spec
53
+ elif isinstance(self.device_spec, str):
54
+ low_spec = self.device_spec.lower()
55
+ for d in sd.query_devices():
56
+ if low_spec in d["name"].lower() and d["max_input_channels"] > 0:
57
+ dev_idx = d["index"]
58
+ break
59
+
60
+ # If no device explicitly specified, search for common loopback/stereo mix endpoints
61
+ if dev_idx is None:
62
+ for d in sd.query_devices():
63
+ d_name = d["name"].lower()
64
+ if d["max_input_channels"] > 0 and any(k in d_name for k in ("stereo mix", "what u hear", "cable output")):
65
+ dev_idx = d["index"]
66
+ break
67
+
68
+ if dev_idx is None:
69
+ log.debug("No loopback/stereo mix reference device available. AEC operating in passive mode.")
70
+ self._running = False
71
+ return False
72
+
73
+ self.stream = sd.InputStream(
74
+ device=dev_idx,
75
+ samplerate=self.sr,
76
+ blocksize=self.block,
77
+ channels=min(2, max(1, sd.query_devices(dev_idx)["max_input_channels"])),
78
+ dtype="float32",
79
+ callback=self._on_audio,
80
+ )
81
+ self.stream.start()
82
+ self._running = True
83
+ log.info("AEC loopback capture started successfully (device %s)", dev_idx)
84
+ return True
85
+ except Exception as exc:
86
+ log.debug("AEC loopback not started: %s (AEC in passive clean mode)", exc)
87
+ self.stream = None
88
+ self._running = False
89
+ return False
90
+
91
+ def _on_audio(self, indata: np.ndarray, frames: int, _time: Any, _status: Any) -> None:
92
+ if self.callback is not None and self._running:
93
+ # Downmix stereo to mono
94
+ mono = np.mean(indata, axis=1, dtype=np.float32)
95
+ try:
96
+ self.callback(mono)
97
+ except Exception:
98
+ pass
99
+
100
+ def stop(self) -> None:
101
+ self._running = False
102
+ if self.stream is not None:
103
+ try:
104
+ self.stream.stop()
105
+ self.stream.close()
106
+ except Exception:
107
+ pass
108
+ self.stream = None
109
+
110
+ @property
111
+ def is_running(self) -> bool:
112
+ return self._running
113
+
@@ -0,0 +1,134 @@
1
+ """Level metering and spectrum analysis for the live UI."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from ..dsp.util import EPS
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class LevelSnapshot:
15
+ peak_db: float = -120.0 # ballistic peak (falls smoothly)
16
+ rms_db: float = -120.0
17
+ hold_db: float = -120.0
18
+ clip_count: int = 0
19
+ clipping: bool = False # clipped within the last second
20
+
21
+
22
+ class LevelMeter:
23
+ def __init__(self, sample_rate: int, fall_db_per_s: float = 26.0, hold_s: float = 1.5, rms_s: float = 0.3):
24
+ self.sr = sample_rate
25
+ self.fall = fall_db_per_s
26
+ self.hold_s = hold_s
27
+ self.rms_s = rms_s
28
+ self.reset()
29
+
30
+ def reset(self) -> None:
31
+ self._peak = -120.0
32
+ self._hold = -120.0
33
+ self._hold_age = 0.0
34
+ self._ms = 0.0
35
+ self._clips = 0
36
+ self._last_clip = -1e9
37
+ self._snap = LevelSnapshot()
38
+
39
+ @property
40
+ def snapshot(self) -> LevelSnapshot:
41
+ return self._snap
42
+
43
+ def update(self, x: np.ndarray) -> LevelSnapshot:
44
+ n = x.shape[0]
45
+ if n == 0:
46
+ return self._snap
47
+ dt = n / self.sr
48
+ a = np.abs(x)
49
+ pk = float(a.max())
50
+ pk_db = 20.0 * math.log10(max(pk, EPS))
51
+ ms = float(np.mean(np.square(x, dtype=np.float64)))
52
+ k = math.exp(-dt / self.rms_s)
53
+ self._ms = k * self._ms + (1.0 - k) * ms
54
+ self._peak = max(pk_db, self._peak - self.fall * dt)
55
+ if pk_db >= self._hold:
56
+ self._hold, self._hold_age = pk_db, 0.0
57
+ else:
58
+ self._hold_age += dt
59
+ if self._hold_age > self.hold_s:
60
+ self._hold = max(pk_db, self._hold - self.fall * dt)
61
+ clipped = int(np.count_nonzero(a >= 0.999))
62
+ now = time.monotonic()
63
+ if clipped:
64
+ self._clips += clipped
65
+ self._last_clip = now
66
+ self._snap = LevelSnapshot(
67
+ peak_db=max(self._peak, -120.0),
68
+ rms_db=10.0 * math.log10(max(self._ms, EPS * EPS)),
69
+ hold_db=max(self._hold, -120.0),
70
+ clip_count=self._clips,
71
+ clipping=(now - self._last_clip) < 1.0,
72
+ )
73
+ return self._snap
74
+
75
+
76
+ class SpectrumAnalyzer:
77
+ """Log-spaced band levels (dBFS per band) with fast attack / slow release."""
78
+
79
+ def __init__(
80
+ self,
81
+ sample_rate: int,
82
+ n_fft: int = 2048,
83
+ n_bands: int = 32,
84
+ f_lo: float = 60.0,
85
+ f_hi: float = 16000.0,
86
+ interval_s: float = 0.05,
87
+ ) -> None:
88
+ self.sr = sample_rate
89
+ self.n_fft = n_fft
90
+ self.n_bands = n_bands
91
+ self.f_lo = f_lo
92
+ self.f_hi = min(f_hi, 0.45 * sample_rate)
93
+ self.interval = interval_s
94
+ self._win = np.hanning(n_fft)
95
+ self._norm = float(np.sum(self._win) / 2.0)
96
+ edges = np.geomspace(self.f_lo, self.f_hi, n_bands + 1)
97
+ idx = np.round(edges / (sample_rate / n_fft)).astype(int)
98
+ for i in range(1, idx.size):
99
+ if idx[i] <= idx[i - 1]:
100
+ idx[i] = idx[i - 1] + 1
101
+ self._idx = np.minimum(idx, n_fft // 2)
102
+ self._counts = np.maximum(np.diff(self._idx), 1)
103
+ self._buf = np.zeros(n_fft, dtype=np.float32)
104
+ self._bands = np.full(n_bands, -120.0)
105
+ self._last = 0.0
106
+ self._dirty = False
107
+
108
+ def _compute(self) -> None:
109
+ self._dirty = False
110
+ spec = np.fft.rfft(self._buf * self._win)
111
+ power = spec.real**2 + spec.imag**2
112
+ sums = np.add.reduceat(power[: self._idx[-1]], self._idx[:-1])
113
+ band_power = sums / self._counts / (self._norm**2)
114
+ db = 10.0 * np.log10(np.maximum(band_power, 1e-14))
115
+ self._bands = np.maximum(db, self._bands - 3.0)
116
+
117
+ @property
118
+ def bands(self) -> np.ndarray:
119
+ if self._dirty:
120
+ self._compute()
121
+ return self._bands
122
+
123
+ def feed(self, x: np.ndarray) -> None:
124
+ n = x.shape[0]
125
+ if n >= self.n_fft:
126
+ self._buf[:] = x[-self.n_fft :]
127
+ elif n > 0:
128
+ self._buf[:-n] = self._buf[n:]
129
+ self._buf[-n:] = x
130
+ self._dirty = True
131
+
132
+ def band_centers(self) -> np.ndarray:
133
+ e = np.geomspace(self.f_lo, self.f_hi, self.n_bands + 1)
134
+ return np.sqrt(e[:-1] * e[1:])