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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Interactive menu shown when `micforge` is run with no arguments."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
|
|
7
|
+
from .. import __version__
|
|
8
|
+
from ..config import AppConfig
|
|
9
|
+
from ..errors import MicforgeError
|
|
10
|
+
from ..ui.theme import console
|
|
11
|
+
from .calibrate import run_calibrate
|
|
12
|
+
from .devices_cmd import run_setup, show_devices, show_presets
|
|
13
|
+
from .doctor import run_doctor
|
|
14
|
+
from .live import run_live
|
|
15
|
+
from .test_mic import run_test
|
|
16
|
+
|
|
17
|
+
MENU = [
|
|
18
|
+
("1", "Start enhancer (mic → virtual cable → Discord)"),
|
|
19
|
+
("2", "Mic test (record, compare, play back raw vs processed)"),
|
|
20
|
+
("3", "Loopback (hear yourself live, A/B raw vs processed)"),
|
|
21
|
+
("4", "Calibrate room noise"),
|
|
22
|
+
("5", "Setup devices"),
|
|
23
|
+
("6", "Presets"),
|
|
24
|
+
("7", "Settings & Hotkeys (global hotkeys, audio cues, autostart)"),
|
|
25
|
+
("8", "Doctor (diagnose problems)"),
|
|
26
|
+
("q", "Quit"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_wizard(app: AppConfig) -> None:
|
|
31
|
+
while True:
|
|
32
|
+
body = "\n".join(f"[bold cyan]{k}[/] {v}" for k, v in MENU)
|
|
33
|
+
console.print(Panel(body, title=f"[bold]micforge[/] v{__version__}", subtitle=f"preset: {app.preset}",
|
|
34
|
+
border_style="cyan", padding=(1, 3)))
|
|
35
|
+
choice = Prompt.ask("Choose", choices=[k for k, _ in MENU], default="1", console=console)
|
|
36
|
+
try:
|
|
37
|
+
if choice == "q":
|
|
38
|
+
return
|
|
39
|
+
if choice == "1":
|
|
40
|
+
run_live(app)
|
|
41
|
+
elif choice == "2":
|
|
42
|
+
run_test(app)
|
|
43
|
+
elif choice == "3":
|
|
44
|
+
run_live(app, loopback=True)
|
|
45
|
+
elif choice == "4":
|
|
46
|
+
run_calibrate(app)
|
|
47
|
+
elif choice == "5":
|
|
48
|
+
show_devices()
|
|
49
|
+
run_setup(app)
|
|
50
|
+
elif choice == "6":
|
|
51
|
+
show_presets(None)
|
|
52
|
+
name = Prompt.ask("Set default preset (Enter to skip)", default="", console=console).strip()
|
|
53
|
+
if name:
|
|
54
|
+
from ..presets import get_preset
|
|
55
|
+
get_preset(name) # validates
|
|
56
|
+
app.preset = name
|
|
57
|
+
app.save()
|
|
58
|
+
console.print(f"[green]Default preset: {name}[/]")
|
|
59
|
+
elif choice == "7":
|
|
60
|
+
from ..ui.settings_gui import launch_settings_gui
|
|
61
|
+
launch_settings_gui(app, standalone=True)
|
|
62
|
+
elif choice == "8":
|
|
63
|
+
run_doctor(app)
|
|
64
|
+
except MicforgeError as exc:
|
|
65
|
+
console.print(f"\n[bold red]✖ {exc}[/]")
|
|
66
|
+
if exc.hint:
|
|
67
|
+
console.print(f"[yellow] → {exc.hint}[/]")
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
console.print()
|
|
70
|
+
console.print()
|
micforge/config.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Typed configuration: DSP chain settings + application settings.
|
|
2
|
+
|
|
3
|
+
Everything is a plain dataclass so it round-trips through JSON and stays easy
|
|
4
|
+
to test. Unknown keys in files are ignored; missing keys fall back to defaults.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import typing
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, TypeVar
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
APP_NAME = "micforge"
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def config_dir() -> Path:
|
|
24
|
+
if os.name == "nt":
|
|
25
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
26
|
+
elif sys.platform == "darwin":
|
|
27
|
+
base = Path.home() / "Library" / "Application Support"
|
|
28
|
+
else:
|
|
29
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
30
|
+
return base / APP_NAME
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build(cls: type[T], data: dict[str, Any]) -> T:
|
|
34
|
+
"""Recursively build a dataclass from a dict, ignoring unknown keys."""
|
|
35
|
+
hints = typing.get_type_hints(cls)
|
|
36
|
+
kwargs: dict[str, Any] = {}
|
|
37
|
+
for f in dataclasses.fields(cls): # type: ignore[arg-type]
|
|
38
|
+
if f.name not in data:
|
|
39
|
+
continue
|
|
40
|
+
value, hint = data[f.name], hints[f.name]
|
|
41
|
+
if dataclasses.is_dataclass(hint) and isinstance(value, dict):
|
|
42
|
+
value = _build(hint, value)
|
|
43
|
+
elif typing.get_origin(hint) is list and isinstance(value, list):
|
|
44
|
+
(inner,) = typing.get_args(hint)
|
|
45
|
+
if dataclasses.is_dataclass(inner):
|
|
46
|
+
value = [_build(inner, v) for v in value if isinstance(v, dict)]
|
|
47
|
+
kwargs[f.name] = value
|
|
48
|
+
return cls(**kwargs) # type: ignore[call-arg]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --------------------------------------------------------------------------- #
|
|
52
|
+
# DSP chain configuration
|
|
53
|
+
# --------------------------------------------------------------------------- #
|
|
54
|
+
@dataclass
|
|
55
|
+
class EQBand:
|
|
56
|
+
kind: str = "peak" # peak | lowshelf | highshelf | highpass | lowpass | bandpass
|
|
57
|
+
freq: float = 1000.0
|
|
58
|
+
gain_db: float = 0.0
|
|
59
|
+
q: float = 0.707
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class AECConfig:
|
|
64
|
+
enabled: bool = False # Enabled when loopback reference is provided or active in preset
|
|
65
|
+
amount: float = 1.0 # 0..1 cancellation intensity
|
|
66
|
+
filter_length_ms: float = 60.0 # acoustic tail memory (20..200 ms)
|
|
67
|
+
delay_ms: float = 40.0 # speaker-to-mic loopback alignment (0..250 ms)
|
|
68
|
+
suppression_db: float = 24.0 # residual echo suppression floor (6..40 dB)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class DeverbConfig:
|
|
73
|
+
enabled: bool = True
|
|
74
|
+
amount: float = 0.5 # 0..1 late reverberation reduction aggression
|
|
75
|
+
room_decay: float = 0.4 # estimated RT60 decay time in seconds (0.15..1.5s)
|
|
76
|
+
early_retention: float = 0.8 # 0..1 preserves natural direct voice warmth
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ProximityConfig:
|
|
81
|
+
enabled: bool = True
|
|
82
|
+
amount: float = 0.5 # 0..1 chest resonance boost intensity
|
|
83
|
+
body_hz: float = 120.0 # center frequency of voice fundamental (70..250 Hz)
|
|
84
|
+
warmth: float = 0.35 # 0..1 even-order low harmonic saturation
|
|
85
|
+
mud_cleanup: float = 0.4 # 0..1 dynamic 300 Hz anti-boxiness cleanup
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class DenoiseConfig:
|
|
90
|
+
enabled: bool = True
|
|
91
|
+
strength: float = 1.0 # 0..1, how aggressively noise is attenuated (1.0 = full neural suppression)
|
|
92
|
+
max_reduction_db: float = 24.0 # attenuation floor per bin
|
|
93
|
+
model_type: str = "cofilite" # "cofilite" | "spectral"
|
|
94
|
+
model_path: str | None = None # path to swappable model file (.onnx or .tar)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class GateConfig:
|
|
99
|
+
enabled: bool = True
|
|
100
|
+
threshold_db: float = -48.0 # soft expander threshold (eliminates ambient room tone/hiss in speech pauses)
|
|
101
|
+
range_db: float = 20.0 # how far the gate closes (soft expander, not a hard mute)
|
|
102
|
+
attack_ms: float = 3.0
|
|
103
|
+
hold_ms: float = 80.0
|
|
104
|
+
release_ms: float = 150.0
|
|
105
|
+
hysteresis_db: float = 4.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class DeEsserConfig:
|
|
110
|
+
enabled: bool = True
|
|
111
|
+
freq_hz: float = 7000.0
|
|
112
|
+
threshold_db: float = -32.0
|
|
113
|
+
ratio: float = 3.0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class LevelerConfig:
|
|
118
|
+
enabled: bool = True
|
|
119
|
+
target_db: float = -20.0
|
|
120
|
+
max_gain_db: float = 9.0
|
|
121
|
+
max_cut_db: float = 6.0
|
|
122
|
+
gate_db: float = -48.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class CompressorConfig:
|
|
127
|
+
enabled: bool = True
|
|
128
|
+
threshold_db: float = -22.0
|
|
129
|
+
ratio: float = 3.0
|
|
130
|
+
attack_ms: float = 6.0
|
|
131
|
+
release_ms: float = 90.0
|
|
132
|
+
knee_db: float = 6.0
|
|
133
|
+
makeup_db: float = 3.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class LimiterConfig:
|
|
138
|
+
enabled: bool = True
|
|
139
|
+
ceiling_db: float = -1.5
|
|
140
|
+
release_ms: float = 60.0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class ChainConfig:
|
|
145
|
+
input_gain_db: float = 0.0
|
|
146
|
+
output_gain_db: float = 0.0
|
|
147
|
+
highpass_hz: float = 90.0 # 0 disables
|
|
148
|
+
aec: AECConfig = field(default_factory=AECConfig)
|
|
149
|
+
denoise: DenoiseConfig = field(default_factory=DenoiseConfig)
|
|
150
|
+
deverb: DeverbConfig = field(default_factory=DeverbConfig)
|
|
151
|
+
gate: GateConfig = field(default_factory=GateConfig)
|
|
152
|
+
proximity: ProximityConfig = field(default_factory=ProximityConfig)
|
|
153
|
+
eq_enabled: bool = True
|
|
154
|
+
eq: list[EQBand] = field(default_factory=list)
|
|
155
|
+
deesser: DeEsserConfig = field(default_factory=DeEsserConfig)
|
|
156
|
+
leveler: LevelerConfig = field(default_factory=LevelerConfig)
|
|
157
|
+
compressor: CompressorConfig = field(default_factory=CompressorConfig)
|
|
158
|
+
limiter: LimiterConfig = field(default_factory=LimiterConfig)
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> dict[str, Any]:
|
|
161
|
+
return dataclasses.asdict(self)
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_dict(cls, data: dict[str, Any]) -> "ChainConfig":
|
|
165
|
+
cfg = _build(cls, data)
|
|
166
|
+
cfg.validate()
|
|
167
|
+
return cfg
|
|
168
|
+
|
|
169
|
+
def validate(self) -> None:
|
|
170
|
+
def check(cond: bool, msg: str) -> None:
|
|
171
|
+
if not cond:
|
|
172
|
+
raise ConfigError(msg)
|
|
173
|
+
|
|
174
|
+
check(-40 <= self.input_gain_db <= 40, "input_gain_db must be within -40..40")
|
|
175
|
+
check(-40 <= self.output_gain_db <= 40, "output_gain_db must be within -40..40")
|
|
176
|
+
check(0 <= self.highpass_hz <= 400, "highpass_hz must be within 0..400")
|
|
177
|
+
check(0.0 <= self.aec.amount <= 1.0, "aec.amount must be within 0..1")
|
|
178
|
+
check(0.0 <= self.deverb.amount <= 1.0, "deverb.amount must be within 0..1")
|
|
179
|
+
check(0.0 <= self.proximity.amount <= 1.0, "proximity.amount must be within 0..1")
|
|
180
|
+
check(0.0 <= self.denoise.strength <= 1.0, "denoise.strength must be within 0..1")
|
|
181
|
+
check(0 <= self.denoise.max_reduction_db <= 60, "denoise.max_reduction_db must be within 0..60")
|
|
182
|
+
check(self.denoise.model_type in ("cofilite", "spectral"), "denoise.model_type must be 'cofilite' or 'spectral'")
|
|
183
|
+
check(self.gate.range_db >= 0, "gate.range_db must be >= 0")
|
|
184
|
+
check(self.compressor.ratio >= 1.0, "compressor.ratio must be >= 1")
|
|
185
|
+
check(self.deesser.ratio >= 1.0, "deesser.ratio must be >= 1")
|
|
186
|
+
check(self.limiter.ceiling_db <= 0, "limiter.ceiling_db must be <= 0")
|
|
187
|
+
valid = {"peak", "lowshelf", "highshelf", "highpass", "lowpass", "bandpass"}
|
|
188
|
+
for b in self.eq:
|
|
189
|
+
check(b.kind in valid, f"unknown EQ band kind {b.kind!r}")
|
|
190
|
+
check(b.freq > 0 and b.q > 0, "EQ band freq and q must be > 0")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------- #
|
|
194
|
+
# Application configuration
|
|
195
|
+
# --------------------------------------------------------------------------- #
|
|
196
|
+
DeviceSpec = typing.Union[str, int, None]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class HotkeyConfig:
|
|
201
|
+
toggle_mute: str = "ctrl+shift+m"
|
|
202
|
+
push_to_mute: str = ""
|
|
203
|
+
push_to_talk: str = ""
|
|
204
|
+
toggle_bypass: str = "ctrl+shift+b"
|
|
205
|
+
play_cues: bool = True
|
|
206
|
+
cue_volume: float = 0.5
|
|
207
|
+
start_in_tray: bool = False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class AppConfig:
|
|
212
|
+
sample_rate: int = 48000
|
|
213
|
+
block_size: int = 480 # 10 ms @ 48 kHz
|
|
214
|
+
jitter_blocks: float = 3.0 # output prefill in blocks; 3.0 = 30 ms rock-solid cushion (glitch-proof for gaming)
|
|
215
|
+
input_device: DeviceSpec = None # name substring or index; None = system default
|
|
216
|
+
output_device: DeviceSpec = None # virtual cable (Discord reads its other end)
|
|
217
|
+
monitor_device: DeviceSpec = None # your headphones, for self-monitoring
|
|
218
|
+
aec_reference_device: DeviceSpec = None # speaker/loopback capture device for AEC (None = auto-detect loopback)
|
|
219
|
+
input_channel: int = 0
|
|
220
|
+
preset: str = "discord"
|
|
221
|
+
denoise_model: str | None = None # custom swappable model path (.onnx or .tar)
|
|
222
|
+
hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def mic(self) -> DeviceSpec:
|
|
226
|
+
return self.input_device
|
|
227
|
+
|
|
228
|
+
@mic.setter
|
|
229
|
+
def mic(self, val: DeviceSpec) -> None:
|
|
230
|
+
self.input_device = val
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def cable(self) -> DeviceSpec:
|
|
234
|
+
return self.output_device
|
|
235
|
+
|
|
236
|
+
@cable.setter
|
|
237
|
+
def cable(self, val: DeviceSpec) -> None:
|
|
238
|
+
self.output_device = val
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def path() -> Path:
|
|
242
|
+
return config_dir() / "config.json"
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def load(cls) -> "AppConfig":
|
|
246
|
+
p = cls.path()
|
|
247
|
+
if not p.exists():
|
|
248
|
+
return cls()
|
|
249
|
+
try:
|
|
250
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
251
|
+
cfg = _build(cls, data)
|
|
252
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
253
|
+
raise ConfigError(
|
|
254
|
+
f"Could not read {p}: {exc}",
|
|
255
|
+
hint=f"Delete {p} to reset to defaults.",
|
|
256
|
+
) from exc
|
|
257
|
+
# Ensure balanced, glitch-resistant default of 3.0 blocks (30ms cushion)
|
|
258
|
+
jb = getattr(cfg, "jitter_blocks", 3.0)
|
|
259
|
+
if jb < 1.0 or jb > 8.0 or jb in (1.5, 2.5):
|
|
260
|
+
cfg.jitter_blocks = 3.0
|
|
261
|
+
cfg.validate()
|
|
262
|
+
return cfg
|
|
263
|
+
|
|
264
|
+
def save(self) -> None:
|
|
265
|
+
p = self.path()
|
|
266
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
tmp = p.with_suffix(".tmp")
|
|
268
|
+
tmp.write_text(json.dumps(dataclasses.asdict(self), indent=2), encoding="utf-8")
|
|
269
|
+
tmp.replace(p)
|
|
270
|
+
|
|
271
|
+
def validate(self) -> None:
|
|
272
|
+
if self.sample_rate not in (16000, 22050, 24000, 32000, 44100, 48000, 96000):
|
|
273
|
+
raise ConfigError(f"Unsupported sample_rate {self.sample_rate}", hint="Use 48000 or 44100.")
|
|
274
|
+
if not 64 <= self.block_size <= 4096:
|
|
275
|
+
raise ConfigError("block_size must be within 64..4096")
|
|
276
|
+
if not 1.0 <= float(self.jitter_blocks) <= 8.0:
|
|
277
|
+
raise ConfigError("jitter_blocks must be within 1.0..8.0")
|
|
278
|
+
if self.input_channel < 0:
|
|
279
|
+
raise ConfigError("input_channel must be >= 0")
|
micforge/diagnostics.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Export diagnostics with redaction, system inspection, and plain-English doctor summary."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
import zipfile
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .audio.backend import get_sd
|
|
19
|
+
from .audio.devices import detect_virtual_output, find_device, list_devices
|
|
20
|
+
from .config import AppConfig, config_dir
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("micforge.diagnostics")
|
|
23
|
+
|
|
24
|
+
SECRET_KEY_PATTERNS = [
|
|
25
|
+
re.compile(r"(api[_-]?key|token|secret|password|auth|webhook[_-]?url|credential)", re.IGNORECASE),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
URL_AUTH_PATTERN = re.compile(r"://([^:]+):([^@]+)@")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def redact_secrets(obj: Any) -> Any:
|
|
32
|
+
"""Recursively redact secrets and user home directories from data structures."""
|
|
33
|
+
user_home = str(Path.home())
|
|
34
|
+
user_home_forward = user_home.replace("\\", "/")
|
|
35
|
+
|
|
36
|
+
def _clean_str(s: str) -> str:
|
|
37
|
+
# Redact embedded credentials in URLs
|
|
38
|
+
s = URL_AUTH_PATTERN.sub("://***:***@", s)
|
|
39
|
+
# Redact home paths
|
|
40
|
+
if user_home in s:
|
|
41
|
+
s = s.replace(user_home, "<USER_HOME>")
|
|
42
|
+
if user_home_forward in s:
|
|
43
|
+
s = s.replace(user_home_forward, "<USER_HOME>")
|
|
44
|
+
return s
|
|
45
|
+
|
|
46
|
+
if isinstance(obj, dict):
|
|
47
|
+
cleaned: dict[str, Any] = {}
|
|
48
|
+
for k, v in obj.items():
|
|
49
|
+
k_str = str(k)
|
|
50
|
+
# Check if key implies secret
|
|
51
|
+
if any(p.search(k_str) for p in SECRET_KEY_PATTERNS):
|
|
52
|
+
cleaned[k_str] = "***REDACTED***"
|
|
53
|
+
else:
|
|
54
|
+
cleaned[k_str] = redact_secrets(v)
|
|
55
|
+
return cleaned
|
|
56
|
+
elif isinstance(obj, (list, tuple)):
|
|
57
|
+
return [redact_secrets(item) for item in obj]
|
|
58
|
+
elif isinstance(obj, str):
|
|
59
|
+
return _clean_str(obj)
|
|
60
|
+
return obj
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class DiagnosticReport:
|
|
65
|
+
"""Complete system diagnostic telemetry."""
|
|
66
|
+
micforge_version: str = __version__
|
|
67
|
+
timestamp: float = field(default_factory=time.time)
|
|
68
|
+
platform_info: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
package_versions: dict[str, str] = field(default_factory=dict)
|
|
70
|
+
config_summary: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
devices: list[dict[str, Any]] = field(default_factory=list)
|
|
72
|
+
engine_stats: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
noise_profile: dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
doctor_checks: list[dict[str, str]] = field(default_factory=list)
|
|
75
|
+
overall_status: str = "HEALTHY" # HEALTHY | WARNING | CRITICAL
|
|
76
|
+
summary_text: str = ""
|
|
77
|
+
next_steps: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return asdict(self)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def collect_diagnostics(
|
|
84
|
+
app: AppConfig,
|
|
85
|
+
engine_snapshot: Any | None = None,
|
|
86
|
+
recent_logs: list[str] | None = None,
|
|
87
|
+
) -> DiagnosticReport:
|
|
88
|
+
"""Gather complete diagnostic data with all secrets redacted."""
|
|
89
|
+
report = DiagnosticReport()
|
|
90
|
+
|
|
91
|
+
# 1. Platform Info
|
|
92
|
+
report.platform_info = {
|
|
93
|
+
"os": platform.system(),
|
|
94
|
+
"release": platform.release(),
|
|
95
|
+
"version": platform.version(),
|
|
96
|
+
"machine": platform.machine(),
|
|
97
|
+
"processor": platform.processor(),
|
|
98
|
+
"python_version": platform.python_version(),
|
|
99
|
+
"python_executable": redact_secrets(sys.executable),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# 2. Package Versions
|
|
103
|
+
pkgs = {"micforge": __version__, "python": sys.version.split()[0]}
|
|
104
|
+
for mod_name in ("sounddevice", "numpy", "scipy", "onnxruntime", "psutil", "rich"):
|
|
105
|
+
try:
|
|
106
|
+
m = __import__(mod_name)
|
|
107
|
+
pkgs[mod_name] = getattr(m, "__version__", "installed")
|
|
108
|
+
except ImportError:
|
|
109
|
+
pkgs[mod_name] = "not installed"
|
|
110
|
+
report.package_versions = pkgs
|
|
111
|
+
|
|
112
|
+
# 3. Redacted Config
|
|
113
|
+
try:
|
|
114
|
+
report.config_summary = redact_secrets(app.to_dict())
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
report.config_summary = {"error": str(exc)}
|
|
117
|
+
|
|
118
|
+
# 4. Audio Devices
|
|
119
|
+
try:
|
|
120
|
+
pool = list_devices(all_apis=True)
|
|
121
|
+
report.devices = [
|
|
122
|
+
{
|
|
123
|
+
"index": d.index,
|
|
124
|
+
"name": d.name,
|
|
125
|
+
"hostapi": d.hostapi,
|
|
126
|
+
"max_input": d.max_input,
|
|
127
|
+
"max_output": d.max_output,
|
|
128
|
+
"default_rate": d.default_rate,
|
|
129
|
+
"is_default_input": d.is_default_input,
|
|
130
|
+
"is_default_output": d.is_default_output,
|
|
131
|
+
}
|
|
132
|
+
for d in pool
|
|
133
|
+
]
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
report.devices = [{"error": str(exc)}]
|
|
136
|
+
|
|
137
|
+
# 5. Engine Runtime Stats
|
|
138
|
+
if engine_snapshot is not None:
|
|
139
|
+
report.engine_stats = {
|
|
140
|
+
"running": getattr(engine_snapshot, "running", False),
|
|
141
|
+
"cpu_load_pct": round(getattr(engine_snapshot, "cpu_load", 0.0) * 100, 1),
|
|
142
|
+
"latency_ms": round(getattr(engine_snapshot, "latency_ms", 0.0), 1),
|
|
143
|
+
"output_underruns": getattr(engine_snapshot, "output_underruns", 0),
|
|
144
|
+
"input_overflows": getattr(engine_snapshot, "input_overflows", 0),
|
|
145
|
+
"drift_corrections": getattr(engine_snapshot, "drift_corrections", 0),
|
|
146
|
+
"errors": getattr(engine_snapshot, "errors", 0),
|
|
147
|
+
"bypass": getattr(engine_snapshot, "bypass", False),
|
|
148
|
+
"muted": getattr(engine_snapshot, "muted", False),
|
|
149
|
+
}
|
|
150
|
+
if hasattr(engine_snapshot, "noise_profile") and engine_snapshot.noise_profile:
|
|
151
|
+
np = engine_snapshot.noise_profile
|
|
152
|
+
report.noise_profile = {
|
|
153
|
+
"name": getattr(np, "name", "unknown"),
|
|
154
|
+
"noise_floor_db": getattr(np, "noise_floor_db", -62.0),
|
|
155
|
+
"gate_threshold_db": getattr(np, "gate_threshold_db", -50.0),
|
|
156
|
+
"frozen": getattr(np, "frozen", False),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# 6. Run Doctor Checks
|
|
160
|
+
_run_doctor_assessment(report, app)
|
|
161
|
+
return report
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _run_doctor_assessment(report: DiagnosticReport, app: AppConfig) -> None:
|
|
165
|
+
"""Generate plain-English health assessment with next steps."""
|
|
166
|
+
checks = []
|
|
167
|
+
issues = 0
|
|
168
|
+
critical = 0
|
|
169
|
+
|
|
170
|
+
# Check 1: Python
|
|
171
|
+
if sys.version_info >= (3, 10):
|
|
172
|
+
checks.append({"name": "Python Runtime", "status": "OK", "detail": f"Python {platform.python_version()} (compatible)"})
|
|
173
|
+
else:
|
|
174
|
+
checks.append({"name": "Python Runtime", "status": "BAD", "detail": f"Python {platform.python_version()} is older than 3.10"})
|
|
175
|
+
critical += 1
|
|
176
|
+
|
|
177
|
+
# Check 2: Audio Backend
|
|
178
|
+
try:
|
|
179
|
+
sd = get_sd()
|
|
180
|
+
pa_ver = str(sd.get_portaudio_version()[1])
|
|
181
|
+
checks.append({"name": "PortAudio Engine", "status": "OK", "detail": pa_ver})
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
checks.append({"name": "PortAudio Engine", "status": "CRITICAL", "detail": f"Could not load audio backend: {exc}"})
|
|
184
|
+
critical += 1
|
|
185
|
+
|
|
186
|
+
# Check 3: Input Microphone
|
|
187
|
+
pool = list_devices(all_apis=True)
|
|
188
|
+
try:
|
|
189
|
+
mic = find_device(app.input_device, "input", pool)
|
|
190
|
+
checks.append({"name": "Microphone", "status": "OK", "detail": f"{mic.name} [{mic.hostapi}]"})
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
checks.append({"name": "Microphone", "status": "CRITICAL", "detail": f"Microphone device not found: {exc}"})
|
|
193
|
+
critical += 1
|
|
194
|
+
|
|
195
|
+
# Check 4: Virtual Cable (for Discord/OBS)
|
|
196
|
+
cable = None
|
|
197
|
+
if app.output_device is not None:
|
|
198
|
+
try:
|
|
199
|
+
cable = find_device(app.output_device, "output", pool)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
if cable is None:
|
|
203
|
+
cable = detect_virtual_output(pool)
|
|
204
|
+
|
|
205
|
+
if cable is not None:
|
|
206
|
+
checks.append({"name": "Virtual Cable", "status": "OK", "detail": f"{cable.name} (Discord/OBS ready)"})
|
|
207
|
+
else:
|
|
208
|
+
checks.append({
|
|
209
|
+
"name": "Virtual Cable",
|
|
210
|
+
"status": "WARN",
|
|
211
|
+
"detail": "No virtual cable (VB-CABLE / BlackHole) detected. Discord won't receive processed audio directly.",
|
|
212
|
+
})
|
|
213
|
+
issues += 1
|
|
214
|
+
|
|
215
|
+
# Check 5: Latency & Real-Time Performance
|
|
216
|
+
if report.engine_stats:
|
|
217
|
+
lat = report.engine_stats.get("latency_ms", 0.0)
|
|
218
|
+
underruns = report.engine_stats.get("output_underruns", 0)
|
|
219
|
+
drift = report.engine_stats.get("drift_corrections", 0)
|
|
220
|
+
if underruns == 0:
|
|
221
|
+
checks.append({"name": "Audio Buffer Sync", "status": "OK", "detail": "Zero dropped glitches, perfect stream sync"})
|
|
222
|
+
else:
|
|
223
|
+
checks.append({"name": "Audio Buffer Sync", "status": "WARN", "detail": f"{underruns} buffer glitches dropped"})
|
|
224
|
+
issues += 1
|
|
225
|
+
|
|
226
|
+
report.doctor_checks = checks
|
|
227
|
+
|
|
228
|
+
# Assign overall status
|
|
229
|
+
if critical > 0:
|
|
230
|
+
report.overall_status = "CRITICAL"
|
|
231
|
+
report.summary_text = "Micforge detected critical configuration or hardware issues preventing normal audio routing."
|
|
232
|
+
report.next_steps = [
|
|
233
|
+
"Check that your USB microphone is plugged in and authorized in OS privacy settings.",
|
|
234
|
+
"Review docs/TROUBLESHOOTING.md for device connection troubleshooting.",
|
|
235
|
+
"Run 'micforge setup' to re-select active microphone and playback endpoints.",
|
|
236
|
+
]
|
|
237
|
+
elif issues > 0:
|
|
238
|
+
report.overall_status = "WARNING"
|
|
239
|
+
report.summary_text = "Micforge is operational, but one or more optimizations (like virtual cable routing) need attention."
|
|
240
|
+
report.next_steps = [
|
|
241
|
+
"Install a free virtual audio cable (e.g. VB-CABLE on Windows or BlackHole on macOS).",
|
|
242
|
+
"Set Discord's Input Device to 'CABLE Output (VB-Audio Virtual Cable)'.",
|
|
243
|
+
"See docs/TROUBLESHOOTING.md for step-by-step app setup guides.",
|
|
244
|
+
]
|
|
245
|
+
else:
|
|
246
|
+
report.overall_status = "HEALTHY"
|
|
247
|
+
report.summary_text = "All systems healthy! Audio routing, noise profile, and DSP pipeline are streaming cleanly."
|
|
248
|
+
report.next_steps = [
|
|
249
|
+
"In Discord/Zoom/OBS, verify your input is set to your Virtual Cable.",
|
|
250
|
+
"Press [Tab] in the live terminal to switch between Studio Inspector and Gamer HUD.",
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def export_diagnostics_zip(
|
|
255
|
+
report: DiagnosticReport,
|
|
256
|
+
output_path: Path | None = None,
|
|
257
|
+
recent_logs: list[str] | None = None,
|
|
258
|
+
) -> Path:
|
|
259
|
+
"""Create a single .zip diagnostic bundle containing redacted config, report, and logs."""
|
|
260
|
+
timestamp_str = time.strftime("%Y%m%d_%H%M%S", time.localtime(report.timestamp))
|
|
261
|
+
if output_path is None:
|
|
262
|
+
target_dir = config_dir() / "diagnostics"
|
|
263
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
zip_path = target_dir / f"micforge_diagnostics_{timestamp_str}.zip"
|
|
265
|
+
else:
|
|
266
|
+
zip_path = Path(output_path)
|
|
267
|
+
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
|
|
269
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
270
|
+
# 1. Full JSON Report
|
|
271
|
+
zf.writestr("diagnostic_report.json", json.dumps(report.to_dict(), indent=2))
|
|
272
|
+
|
|
273
|
+
# 2. Plain-English Doctor Summary
|
|
274
|
+
doc_lines = [
|
|
275
|
+
f"Micforge Diagnostic Doctor Summary (v{report.micforge_version})",
|
|
276
|
+
f"Generated: {time.ctime(report.timestamp)}",
|
|
277
|
+
"=" * 60,
|
|
278
|
+
f"Overall Status: {report.overall_status}",
|
|
279
|
+
f"Summary: {report.summary_text}",
|
|
280
|
+
"",
|
|
281
|
+
"Health Checks:",
|
|
282
|
+
]
|
|
283
|
+
for c in report.doctor_checks:
|
|
284
|
+
doc_lines.append(f" [{c['status']:<4}] {c['name']:<24}: {c['detail']}")
|
|
285
|
+
doc_lines.append("")
|
|
286
|
+
doc_lines.append("Recommended Next Steps:")
|
|
287
|
+
for step in report.next_steps:
|
|
288
|
+
doc_lines.append(f" • {step}")
|
|
289
|
+
doc_lines.append("")
|
|
290
|
+
doc_lines.append("See docs/TROUBLESHOOTING.md for comprehensive solutions.")
|
|
291
|
+
zf.writestr("doctor_summary.txt", "\n".join(doc_lines))
|
|
292
|
+
|
|
293
|
+
# 3. Audio Devices Inventory
|
|
294
|
+
dev_lines = ["Audio Devices Inventory:\n"]
|
|
295
|
+
for d in report.devices:
|
|
296
|
+
dev_lines.append(f"[{d.get('index', '?')}] {d.get('name', 'unknown')} (in={d.get('max_input', 0)}, out={d.get('max_output', 0)}) hostapi={d.get('hostapi', '?')}")
|
|
297
|
+
zf.writestr("devices.txt", "\n".join(dev_lines))
|
|
298
|
+
|
|
299
|
+
# 4. Redacted Config
|
|
300
|
+
zf.writestr("config_redacted.json", json.dumps(report.config_summary, indent=2))
|
|
301
|
+
|
|
302
|
+
# 5. Recent Logs
|
|
303
|
+
log_content = "\n".join(recent_logs) if recent_logs else "No active log buffer captured."
|
|
304
|
+
zf.writestr("recent_logs.txt", redact_secrets(log_content))
|
|
305
|
+
|
|
306
|
+
log.info("Exported diagnostics zip to %s", zip_path)
|
|
307
|
+
return zip_path
|
|
308
|
+
|
micforge/dsp/__init__.py
ADDED
|
File without changes
|