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
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
|
+
|