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
micforge/cli.py ADDED
@@ -0,0 +1,293 @@
1
+ """Command-line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .config import AppConfig
9
+ from .errors import MicforgeError
10
+ from .logging_setup import setup_logging
11
+ from .ui.theme import console
12
+
13
+
14
+ def _device_arg(v: str):
15
+ return int(v) if v.isdigit() else v
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ p = argparse.ArgumentParser(
20
+ prog="micforge",
21
+ description="Real-time mic clean-up for Discord & co: de-muffle, denoise, level, route via a virtual cable.",
22
+ )
23
+ p.add_argument("-v", "--version", action="version", version=f"micforge {__version__}")
24
+ p.add_argument("--debug", action="store_true", help="verbose log file")
25
+ p.add_argument("--model", help="path to swappable CoFi-Lite model file (.onnx or .tar)")
26
+ p.add_argument("--tray", action="store_true", help="start live enhancer in background system tray mode")
27
+ sub = p.add_subparsers(dest="cmd")
28
+
29
+ r = sub.add_parser("run", help="live: mic -> processing -> virtual cable (what Discord hears)")
30
+ r.add_argument("-p", "--preset", help="DSP preset to load (e.g. streaming, gaming, podcast, noisy_room)")
31
+ r.add_argument("--mic", type=_device_arg, help="input device (index or name part)")
32
+ r.add_argument("--cable", type=_device_arg, help="virtual cable output (index or name part)")
33
+ r.add_argument("--monitor", nargs="?", const="", type=_device_arg, metavar="DEVICE",
34
+ help="also play the processed mic to your headphones")
35
+ r.add_argument("--rate", type=int, help="sample rate (default from config: 48000)")
36
+ r.add_argument("--block", type=int, help="block size in samples (default 480)")
37
+ r.add_argument("--model", help="swappable CoFi-Lite model file (.onnx or .tar)")
38
+ r.add_argument("--tray", action="store_true", help="run in background with system tray icon")
39
+ r.add_argument("--no-tray", action="store_true", help="disable system tray icon")
40
+
41
+ l = sub.add_parser("loopback", help="hear yourself live through headphones, A/B raw vs processed")
42
+ l.add_argument("-p", "--preset", help="DSP preset to load (default: active preset)")
43
+ l.add_argument("--mic", type=_device_arg, help="input microphone (index or substring)")
44
+ l.add_argument("--out", type=_device_arg, help="headphones device (index or substring)")
45
+ l.add_argument("--model", help="swappable CoFi-Lite model file (.onnx or .tar)")
46
+
47
+ t = sub.add_parser("test", help="record a clip, show before/after analysis, play both back")
48
+ t.add_argument("-p", "--preset", help="DSP preset to test (default: active preset)")
49
+ t.add_argument("-s", "--seconds", type=float, default=8.0, help="duration in seconds to record sample")
50
+ t.add_argument("--mic", type=_device_arg, help="microphone device (index or substring)")
51
+ t.add_argument("--out", type=_device_arg, help="playback headphones device (index or substring)")
52
+ t.add_argument("--model", help="swappable CoFi-Lite model file (.onnx or .tar)")
53
+
54
+ c = sub.add_parser("calibrate", help="learn your room noise (improves denoise + sets the gate)")
55
+ c.add_argument("-s", "--seconds", type=float, default=4.0, help="duration in seconds of ambient silence to sample")
56
+ c.add_argument("--mic", type=_device_arg, help="microphone device (index or substring)")
57
+ c.add_argument("--reset", action="store_true", help="delete the saved noise profile")
58
+
59
+ d = sub.add_parser("devices", help="list audio devices")
60
+ d.add_argument("--all", action="store_true", help="show every host API (MME, DirectSound, ...)")
61
+
62
+ sub.add_parser("setup", help="choose mic / cable / headphones interactively")
63
+
64
+ # Preset management suite
65
+ p_cmd = sub.add_parser("preset", help="manage and create custom presets")
66
+ p_sub = p_cmd.add_subparsers(dest="preset_action")
67
+ p_sub.add_parser("list", help="list all presets")
68
+ p_save = p_sub.add_parser("save", help="create or save a custom preset")
69
+ p_save.add_argument("name", help="new preset name")
70
+ p_save.add_argument("--from", dest="from_preset", help="base preset to clone from")
71
+ p_save.add_argument("--desc", default="", help="human description for the preset")
72
+ p_save.add_argument("--force", action="store_true", help="overwrite if exists")
73
+ p_copy = p_sub.add_parser("copy", help="duplicate an existing preset")
74
+ p_copy.add_argument("source", help="source preset")
75
+ p_copy.add_argument("target", help="target new preset name")
76
+ p_copy.add_argument("--force", action="store_true", help="overwrite if exists")
77
+ p_del = p_sub.add_parser("delete", help="delete a custom preset")
78
+ p_del.add_argument("name", help="preset name to delete")
79
+ p_del.add_argument("-y", "--yes", action="store_true", help="skip confirmation")
80
+ p_exp = p_sub.add_parser("export", help="export preset to portable JSON")
81
+ p_exp.add_argument("name", help="preset to export")
82
+ p_exp.add_argument("target", nargs="?", help="output file path")
83
+ p_imp = p_sub.add_parser("import", help="import preset from JSON file")
84
+ p_imp.add_argument("source", help="path to preset JSON file")
85
+ p_imp.add_argument("--name", default=None, help="custom name for imported preset")
86
+ p_imp.add_argument("--force", action="store_true", help="overwrite if exists")
87
+ p_show = p_sub.add_parser("show", help="show preset details")
88
+ p_show.add_argument("name", help="preset name")
89
+
90
+ pr = sub.add_parser("presets", help="list presets, or print one as JSON (alias for `micforge preset`)")
91
+ pr.add_argument("name", nargs="?")
92
+
93
+ f = sub.add_parser("process", help="process a WAV file offline")
94
+ f.add_argument("input")
95
+ f.add_argument("output")
96
+ f.add_argument("-p", "--preset")
97
+ f.add_argument("--model", help="swappable CoFi-Lite model file (.onnx or .tar)")
98
+
99
+ a = sub.add_parser("analyze", help="analyse a WAV file (level, noise, clarity) and give advice")
100
+ a.add_argument("input")
101
+
102
+ m_cmd = sub.add_parser("model", help="inspect or export swappable CoFi-Lite models")
103
+ m_cmd.add_argument("action", choices=["info", "export"], nargs="?", default="info")
104
+ m_cmd.add_argument("checkpoint", nargs="?", help="source .tar / .pth checkpoint")
105
+ m_cmd.add_argument("output", nargs="?", help="output .onnx file")
106
+
107
+ doc = sub.add_parser("doctor", help="check your setup and performance (plain-English diagnostics)")
108
+ doc.add_argument("--export", nargs="?", const="", help="export diagnostic report as a redacted .zip bundle")
109
+
110
+ lat = sub.add_parser("latency", help="measure end-to-end audio pipeline latency with loopback probe")
111
+ lat.add_argument("--mic", type=_device_arg, help="input microphone device (index or substring)")
112
+ lat.add_argument("--out", type=_device_arg, help="output playback device (index or substring)")
113
+
114
+ prof = sub.add_parser("profile", help="manage noise profiles (quiet_room, fan_on, mechanical_keyboard)")
115
+ prof.add_argument("action", choices=["list", "show", "relearn", "switch"], default="list", nargs="?",
116
+ help="profile action to perform (list, show, relearn, switch)")
117
+ prof.add_argument("name", nargs="?", help="profile / environment name")
118
+
119
+ sub.add_parser("version", help="show micforge version and system information")
120
+ sub.add_parser("settings", help="open GUI to customize global hotkeys, audio cues, and startup")
121
+ sub.add_parser("hotkeys", help="alias for settings")
122
+ help_p = sub.add_parser("help", help="show comprehensive command guide and dynamic usage examples")
123
+ help_p.add_argument("command", nargs="?", help="specific command to inspect (e.g. run, latency, profile, doctor)")
124
+ return p
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ args = build_parser().parse_args(argv)
129
+ setup_logging(args.debug)
130
+ try:
131
+ app = AppConfig.load()
132
+ cmd = args.cmd
133
+ model_arg = getattr(args, "model", None)
134
+ if model_arg:
135
+ app.denoise_model = model_arg
136
+ if cmd is None and getattr(args, "tray", False):
137
+ cmd = "run"
138
+ if cmd is None:
139
+ from .commands.wizard import run_wizard
140
+ run_wizard(app)
141
+ elif cmd == "version":
142
+ from . import __version__
143
+ from .dsp.cofilite import find_model_file
144
+ console.print(f"[bold cyan]micforge[/] [bold green]v{__version__}[/]")
145
+ active = find_model_file(app.denoise_model)
146
+ model_str = f"CoFi-Lite ({active.name})" if active else "Spectral Wiener"
147
+ console.print(f" [dim]Active Model:[/] {model_str}")
148
+ console.print(f" [dim]Python:[/] {sys.version.split()[0]} on {sys.platform}")
149
+ return 0
150
+ elif cmd == "run":
151
+ from .commands.live import run_live
152
+ if args.rate:
153
+ app.sample_rate = args.rate
154
+ if args.block:
155
+ app.block_size = args.block
156
+ app.validate()
157
+ run_live(app, preset=args.preset, monitor=args.monitor is not None, mic=args.mic, cable=args.cable,
158
+ monitor_dev=(args.monitor or None) if args.monitor is not None else None,
159
+ model=model_arg, tray=getattr(args, "tray", False), no_tray=getattr(args, "no_tray", False))
160
+ elif cmd == "loopback":
161
+ from .commands.live import run_live
162
+ run_live(app, preset=args.preset, loopback=True, mic=args.mic, monitor_dev=args.out, model=model_arg)
163
+ elif cmd == "test":
164
+ from .commands.test_mic import run_test
165
+ run_test(app, preset=args.preset, seconds=max(4.0, args.seconds), mic=args.mic, out=args.out)
166
+ elif cmd == "calibrate":
167
+ from .commands.calibrate import run_calibrate
168
+ run_calibrate(app, seconds=max(2.0, args.seconds), mic=args.mic, reset=args.reset)
169
+ elif cmd == "devices":
170
+ from .commands.devices_cmd import show_devices
171
+ show_devices(args.all)
172
+ elif cmd == "setup":
173
+ from .commands.devices_cmd import run_setup, show_devices
174
+ run_setup(app)
175
+ elif cmd in ("preset", "presets"):
176
+ from .commands.preset_cmd import (
177
+ run_preset_copy,
178
+ run_preset_delete,
179
+ run_preset_export,
180
+ run_preset_import,
181
+ run_preset_list,
182
+ run_preset_save,
183
+ run_preset_show,
184
+ )
185
+ action = getattr(args, "preset_action", None)
186
+ if cmd == "presets":
187
+ if args.name:
188
+ run_preset_show(args.name)
189
+ else:
190
+ run_preset_list(app)
191
+ elif action in (None, "list"):
192
+ run_preset_list(app)
193
+ elif action == "save":
194
+ run_preset_save(app, args.name, from_preset=args.from_preset, description=args.desc, force=args.force)
195
+ elif action == "copy":
196
+ run_preset_copy(args.source, args.target, force=args.force)
197
+ elif action == "delete":
198
+ run_preset_delete(args.name, force=args.yes)
199
+ elif action == "export":
200
+ run_preset_export(args.name, args.target)
201
+ elif action == "import":
202
+ run_preset_import(args.source, name=args.name, force=args.force)
203
+ elif action == "show":
204
+ run_preset_show(args.name)
205
+ elif cmd == "process":
206
+ from .commands.files import run_process
207
+ run_process(app, args.input, args.output, args.preset)
208
+ elif cmd == "analyze":
209
+ from .commands.files import run_analyze
210
+ run_analyze(app, args.input)
211
+ elif cmd == "model":
212
+ from pathlib import Path
213
+ from .dsp.cofilite import find_model_file
214
+ if args.action == "info":
215
+ active = find_model_file(model_arg or app.denoise_model)
216
+ if active and active.is_file():
217
+ size_kb = active.stat().st_size / 1024
218
+ console.print(f"[bold green]βœ” Active CoFi-Lite model:[/] {active} ({size_kb:.1f} KB)")
219
+ console.print(" [dim]Type:[/] ONNX neural speech enhancement (~83k params)")
220
+ console.print(" [dim]Memory:[/] ~65-75 MB RAM (well under 100 MB limit)")
221
+ else:
222
+ console.print("[yellow]! No CoFi-Lite model file found. Operating with fallback Spectral Wiener denoiser.[/]")
223
+ elif args.action == "export":
224
+ if not args.checkpoint:
225
+ console.print("[red]βœ– Please provide checkpoint path: micforge model export <checkpoint.tar> [out.onnx][/]")
226
+ return 1
227
+ from .dsp.export_cofilite import export_checkpoint_to_onnx
228
+ target = args.output or Path(args.checkpoint).with_suffix(".onnx")
229
+ export_checkpoint_to_onnx(args.checkpoint, target)
230
+ console.print(f"[bold green]βœ” Successfully exported CoFi-Lite model to:[/] {target}")
231
+ elif cmd == "doctor":
232
+ from .commands.doctor import run_doctor
233
+ export_flag = args.export is not None
234
+ export_path = args.export if (args.export and len(args.export) > 0) else None
235
+ return run_doctor(app, export=export_flag, export_path=export_path)
236
+ elif cmd == "latency":
237
+ from .commands.latency_test import run_latency_test
238
+ return run_latency_test(app, mic=args.mic, out=args.out)
239
+ elif cmd == "profile":
240
+ action = args.action or "list"
241
+ if action == "list":
242
+ from .audio.noise_profile import BUILTIN_ENVIRONMENTS, list_noise_profiles
243
+ profiles = list_noise_profiles()
244
+ console.print("\n[bold cyan]πŸŽ™οΈ Available Noise Profiles & Environments:[/]")
245
+ for p in profiles:
246
+ tag = " [green](builtin)[/]" if p in BUILTIN_ENVIRONMENTS else " [dim](custom)[/]"
247
+ console.print(f" β€’ [bold white]{p}[/]{tag}")
248
+ console.print("\n[dim]Usage: micforge profile switch <name> β”‚ micforge profile show <name>[/]\n")
249
+ return 0
250
+ elif action == "show":
251
+ from .audio.noise_profile import BUILTIN_ENVIRONMENTS, AdaptiveNoiseProfiler
252
+ prof_name = args.name or "quiet_room"
253
+ profiler = AdaptiveNoiseProfiler(sample_rate=app.sample_rate, environment=prof_name)
254
+ p = profiler.current_profile
255
+ console.print(f"\n[bold cyan]Noise Profile:[/] [bold green]{p.name}[/]")
256
+ console.print(f" β€’ Estimated Floor: [yellow]{p.noise_floor_db:.1f} dBFS[/]")
257
+ console.print(f" β€’ Gate Threshold: [yellow]{p.gate_threshold_db:.1f} dBFS[/]")
258
+ console.print(f" β€’ Spectral Tilt: [dim]{p.spectral_tilt:+.2f}[/]")
259
+ console.print(f" β€’ Status: {'[cyan]Frozen[/]' if p.frozen else '[green]Active Learner[/]'}\n")
260
+ return 0
261
+ elif action == "switch":
262
+ if not args.name:
263
+ console.print("[red]βœ– Please specify environment profile name (e.g. quiet_room, fan_on, mechanical_keyboard)[/]")
264
+ return 1
265
+ from .audio.noise_profile import AdaptiveNoiseProfiler
266
+ profiler = AdaptiveNoiseProfiler(sample_rate=app.sample_rate, environment=args.name)
267
+ profiler.save()
268
+ console.print(f"[bold green]βœ” Switched active noise profile environment to:[/] [bold cyan]{args.name}[/]")
269
+ return 0
270
+ elif action == "relearn":
271
+ console.print("[bold cyan]πŸ”„ Requesting immediate noise profile relearn…[/]")
272
+ console.print("[dim]Next live or test session will automatically sample ambient silence.[/]")
273
+ return 0
274
+ elif cmd in ("settings", "hotkeys"):
275
+ from .ui.settings_gui import launch_settings_gui
276
+ launch_settings_gui(app, standalone=True)
277
+ return 0
278
+ elif cmd == "help":
279
+ from .commands.help_cmd import run_help
280
+ return run_help(args.command)
281
+ return 0
282
+ except MicforgeError as exc:
283
+ console.print(f"[bold red]βœ– {exc}[/]")
284
+ if exc.hint:
285
+ console.print(f"[yellow] β†’ {exc.hint}[/]")
286
+ return 2
287
+ except KeyboardInterrupt:
288
+ console.print("\n[dim]bye[/]")
289
+ return 130
290
+
291
+
292
+ if __name__ == "__main__":
293
+ sys.exit(main())
File without changes
@@ -0,0 +1,47 @@
1
+ """`calibrate`: learn the room noise and set the gate threshold."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ from rich.progress import BarColumn, Progress, TextColumn
7
+
8
+ from ..audio.devices import find_device, list_devices
9
+ from ..audio.recorder import Recorder
10
+ from ..config import AppConfig
11
+ from ..dsp.denoise import SpectralDenoiser
12
+ from ..dsp.util import rms_db
13
+ from ..errors import MicforgeError
14
+ from ..profile_store import clear_profile, save_profile
15
+ from ..ui.theme import console
16
+
17
+
18
+ def run_calibrate(app: AppConfig, *, seconds: float = 4.0, mic=None, reset: bool = False) -> None:
19
+ if reset:
20
+ console.print("[green]Noise profile removed.[/]" if clear_profile(app.sample_rate) else "[dim]No profile saved.[/]")
21
+ return
22
+ dev = find_device(mic if mic is not None else app.input_device, "input", list_devices(all_apis=True))
23
+ console.print(f"[bold]Calibrating[/] on [cyan]{dev.name}[/]. Stay quiet and leave fans/PC running as usual.")
24
+ rec = Recorder(dev, app.sample_rate, app.input_channel, app.block_size)
25
+ rec.start()
26
+ try:
27
+ with Progress(TextColumn("[yellow]listening…"), BarColumn(), TextColumn("{task.percentage:>3.0f}%"), console=console) as p:
28
+ task = p.add_task("", total=seconds)
29
+ while rec.seconds < seconds:
30
+ p.update(task, completed=min(rec.seconds, seconds))
31
+ time.sleep(0.05)
32
+ p.update(task, completed=seconds)
33
+ finally:
34
+ noise = rec.stop()
35
+ if noise.shape[0] < app.sample_rate:
36
+ raise MicforgeError("Not enough audio captured.")
37
+ if rec.level.clipping:
38
+ console.print("[yellow]The mic clipped while calibrating - is something very loud nearby?[/]")
39
+
40
+ dn = SpectralDenoiser(app.sample_rate)
41
+ psd = dn.learn(noise)
42
+ noise_db = rms_db(noise)
43
+ gate = noise_db + 9.0
44
+ save_profile(psd, app.sample_rate, noise_db, gate)
45
+ console.print(f"[green]βœ” Room noise {noise_db:.1f} dBFS.[/] Gate threshold {gate:.1f} dBFS. Profile saved.")
46
+ if noise_db > -45:
47
+ console.print("[yellow]That is loud for a room. Try moving the mic closer to your mouth or away from fans.[/]")
@@ -0,0 +1,69 @@
1
+ """Shared plumbing for commands: resolving devices and building a chain with the saved noise profile."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ..audio.devices import DeviceInfo, detect_virtual_output, find_device, list_devices
7
+ from ..config import AppConfig
8
+ from ..dsp.chain import Chain, build_chain
9
+ from ..errors import DeviceError
10
+ from ..presets import get_preset
11
+ from ..profile_store import load_profile
12
+
13
+
14
+ def make_chain(app: AppConfig, preset: str, model_override: str | None = None) -> tuple[Chain, bool]:
15
+ """Build a chain for `preset`, applying the saved room-noise profile. Returns (chain, profile_applied)."""
16
+ cfg = get_preset(preset)
17
+ prof = load_profile(app.sample_rate)
18
+ model = model_override or app.denoise_model
19
+ chain = build_chain(cfg, app.sample_rate, noise_profile=prof[0] if prof else None, model_override=model)
20
+ applied = False
21
+ if prof:
22
+ dn = chain.stage("denoise")
23
+ applied = bool(dn and getattr(dn, "profile", None) is not None)
24
+ gate = chain.stage("gate")
25
+ if applied and gate is not None and cfg.gate.enabled:
26
+ gate.threshold_db = prof[2] # type: ignore[attr-defined]
27
+ return chain, applied
28
+
29
+
30
+ @dataclass
31
+ class Devices:
32
+ mic: DeviceInfo
33
+ cable: DeviceInfo | None
34
+ monitor: DeviceInfo | None
35
+
36
+
37
+ def resolve_devices(
38
+ app: AppConfig,
39
+ *,
40
+ need_cable: bool,
41
+ want_monitor: bool,
42
+ mic_override=None,
43
+ cable_override=None,
44
+ monitor_override=None,
45
+ ) -> Devices:
46
+ pool = list_devices(all_apis=True)
47
+ mic = find_device(mic_override if mic_override is not None else app.input_device, "input", pool)
48
+
49
+ cable = None
50
+ if need_cable:
51
+ spec = cable_override if cable_override is not None else app.output_device
52
+ if spec is not None:
53
+ cable = find_device(spec, "output", pool)
54
+ else:
55
+ cable = detect_virtual_output(pool)
56
+ if cable is None:
57
+ raise DeviceError(
58
+ "No virtual audio cable found - Discord needs one to receive the processed mic.",
59
+ hint="Run `micforge doctor` for install steps (VB-CABLE on Windows, BlackHole on macOS), "
60
+ "or use `micforge loopback` to just hear yourself.",
61
+ )
62
+
63
+ monitor = None
64
+ if want_monitor:
65
+ spec = monitor_override if monitor_override is not None else app.monitor_device
66
+ monitor = find_device(spec, "output", pool)
67
+ if cable is not None and monitor.index == cable.index:
68
+ raise DeviceError("Monitor and virtual cable are the same device - pick your headphones for monitoring.")
69
+ return Devices(mic, cable, monitor)
@@ -0,0 +1,86 @@
1
+ """`devices`, `setup`, `presets`."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from rich import box
7
+ from rich.prompt import Prompt
8
+ from rich.table import Table
9
+
10
+ from ..audio.devices import DeviceInfo, detect_virtual_output, list_devices
11
+ from ..config import AppConfig
12
+ from ..presets import get_preset, list_presets, user_preset_dir
13
+ from ..ui.theme import console
14
+
15
+
16
+ def show_devices(all_apis: bool = False) -> list[DeviceInfo]:
17
+ devs = list_devices(all_apis)
18
+ cable = detect_virtual_output(devs)
19
+ t = Table(box=box.SIMPLE_HEAD, header_style="bold cyan")
20
+ for c in ("#", "name", "api", "in", "out", ""):
21
+ t.add_column(c, justify="right" if c in ("#", "in", "out") else "left")
22
+ for d in devs:
23
+ tags = []
24
+ if d.is_default_input:
25
+ tags.append("default in")
26
+ if d.is_default_output:
27
+ tags.append("default out")
28
+ if cable is not None and d.index == cable.index:
29
+ tags.append("[green]virtual cable[/]")
30
+ t.add_row(str(d.index), d.name, d.hostapi, str(d.max_input or ""), str(d.max_output or ""), ", ".join(tags))
31
+ console.print(t)
32
+ return devs
33
+
34
+
35
+ def _pick(devs: list[DeviceInfo], kind: str, prompt: str, current, allow_skip: bool = False):
36
+ pool = [d for d in devs if (d.max_input if kind == "input" else d.max_output) > 0]
37
+ t = Table(box=box.SIMPLE_HEAD, header_style="bold cyan")
38
+ t.add_column("#", justify="right")
39
+ t.add_column("name")
40
+ t.add_column("api")
41
+ for d in pool:
42
+ t.add_row(str(d.index), d.name, d.hostapi)
43
+ console.print(t)
44
+ valid = {d.index for d in pool}
45
+ hint = f" (Enter = keep {current!r})" if current not in (None, "") else " (Enter = system default)"
46
+ while True:
47
+ raw = Prompt.ask(prompt + hint, default="", show_default=False, console=console).strip()
48
+ if raw == "":
49
+ return current
50
+ if raw.isdigit() and int(raw) in valid:
51
+ return next(d.name for d in pool if d.index == int(raw)) # store the name: indices change between boots
52
+ console.print("[red]Not a valid device number.[/]")
53
+
54
+
55
+ def run_setup(app: AppConfig) -> None:
56
+ devs = list_devices()
57
+ console.rule("[bold cyan]Setup")
58
+ console.print("[bold]1/3 Your microphone[/]")
59
+ app.input_device = _pick(devs, "input", "Mic device #", app.input_device)
60
+ cable = detect_virtual_output(devs)
61
+ console.print("\n[bold]2/3 Virtual cable output[/] (this is what Discord listens to)")
62
+ if cable:
63
+ console.print(f"Detected: [green]{cable.name}[/]")
64
+ app.output_device = _pick(devs, "output", "Cable device #", app.output_device or (cable.name if cable else None))
65
+ console.print("\n[bold]3/3 Your headphones[/] (for monitoring and test playback)")
66
+ app.monitor_device = _pick(devs, "output", "Headphones #", app.monitor_device)
67
+ app.save()
68
+ console.print("\n[green]βœ” Saved.[/]")
69
+ console.print(
70
+ "\n[bold]Now in Discord:[/] Settings β†’ Voice & Video β†’ Input Device = the cable's [bold]recording[/] end "
71
+ "(Windows: [cyan]CABLE Output[/]). Set Noise Suppression to [bold]None[/], and turn off Automatic Gain Control "
72
+ "and Echo Cancellation so your voice isn't processed twice."
73
+ )
74
+
75
+
76
+ def show_presets(name: str | None) -> None:
77
+ if name:
78
+ console.print_json(json.dumps(get_preset(name).to_dict()))
79
+ return
80
+ t = Table(box=box.SIMPLE_HEAD, header_style="bold cyan")
81
+ t.add_column("preset")
82
+ t.add_column("description")
83
+ for p in list_presets():
84
+ t.add_row(p.name + ("" if p.builtin else " *"), p.description)
85
+ console.print(t)
86
+ console.print(f"[dim]`micforge presets NAME` prints a preset as JSON. Drop edited copies into {user_preset_dir()} to use them.[/]")
@@ -0,0 +1,68 @@
1
+ """CLI command to export diagnostics and display the plain-English doctor summary."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from rich import box
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from ..config import AppConfig
13
+ from ..diagnostics import collect_diagnostics, export_diagnostics_zip
14
+ from ..ui.theme import console
15
+
16
+
17
+ def run_diagnostics_command(
18
+ app: AppConfig,
19
+ export_path: str | Path | None = None,
20
+ export: bool = False,
21
+ ) -> int:
22
+ """Run diagnostics checks, print plain-English summary, and optionally export .zip."""
23
+ console.print("\n[bold cyan]πŸ” Inspecting Micforge system health & audio pipeline…[/]\n")
24
+
25
+ report = collect_diagnostics(app)
26
+
27
+ # 1. Status Banner
28
+ status_style = "bold green" if report.overall_status == "HEALTHY" else "bold yellow" if report.overall_status == "WARNING" else "bold red"
29
+ status_icon = "βœ”" if report.overall_status == "HEALTHY" else "!" if report.overall_status == "WARNING" else "βœ–"
30
+ console.print(Panel(
31
+ f"[{status_style}]{status_icon} SYSTEM STATUS: {report.overall_status}[/]\n{report.summary_text}",
32
+ border_style=status_style.split()[-1],
33
+ box=box.ROUNDED,
34
+ ))
35
+
36
+ # 2. Checks Table
37
+ t = Table(box=box.SIMPLE_HEAD, header_style="bold cyan", show_lines=False)
38
+ t.add_column("", width=3)
39
+ t.add_column("Component", width=22)
40
+ t.add_column("Assessment")
41
+
42
+ for c in report.doctor_checks:
43
+ st = c["status"]
44
+ mark = "[bold green]βœ”[/]" if st == "OK" else "[bold yellow]![/]" if st == "WARN" else "[bold red]βœ–[/]"
45
+ t.add_row(mark, c["name"], c["detail"])
46
+
47
+ console.print(t)
48
+
49
+ # 3. Next Steps / Actionable Advice
50
+ if report.next_steps:
51
+ console.print("[bold cyan]Recommended Next Steps:[/]")
52
+ for step in report.next_steps:
53
+ console.print(f" [dim]β€’[/] {step}")
54
+ console.print()
55
+
56
+ console.print("[dim]πŸ“– For in-depth troubleshooting guides, see:[/] [bold cyan]docs/TROUBLESHOOTING.md[/]\n")
57
+
58
+ # 4. Optional Export
59
+ if export or export_path:
60
+ out = export_diagnostics_zip(report, Path(export_path) if export_path else None)
61
+ console.print(Panel(
62
+ f"[bold green]βœ” Diagnostics exported successfully![/]\n[white]{out}[/]\n[dim]All passwords, API tokens, and home directory paths have been securely redacted.[/]",
63
+ border_style="green",
64
+ box=box.ROUNDED,
65
+ ))
66
+
67
+ return 0 if report.overall_status != "CRITICAL" else 1
68
+