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,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")
@@ -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
+
File without changes