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,65 @@
1
+ """CLI command to run end-to-end latency benchmarks and stage breakdowns."""
2
+ from __future__ import annotations
3
+
4
+ from rich import box
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from ..audio.latency import measure_end_to_end_latency
10
+ from ..config import AppConfig
11
+ from ..ui.theme import console
12
+
13
+
14
+ def run_latency_test(
15
+ app: AppConfig,
16
+ mic: int | str | None = None,
17
+ out: int | str | None = None,
18
+ ) -> int:
19
+ """Run interactive or automated latency test and display breakdown."""
20
+ console.print("\n[bold cyan]⏱️ Running end-to-end audio latency benchmark…[/]\n")
21
+
22
+ input_dev = mic or app.input_device
23
+ output_dev = out or app.output_device or app.monitor_device
24
+
25
+ result = measure_end_to_end_latency(
26
+ input_device=input_dev,
27
+ output_device=output_dev,
28
+ sample_rate=app.sample_rate,
29
+ block_size=app.block_size,
30
+ jitter_blocks=app.jitter_blocks,
31
+ )
32
+
33
+ # 1. Main Latency Banner
34
+ lat_style = "bold green" if result.one_way_ms < 60.0 else "bold yellow" if result.one_way_ms < 100.0 else "bold red"
35
+ summary_text = (
36
+ f"[{lat_style}]Estimated One-Way Latency: ~{result.one_way_ms:.1f} ms[/]\n"
37
+ f"[dim]Measured Round-Trip Delay: {result.roundtrip_ms:.1f} ms │ "
38
+ f"Confidence: {result.confidence_score * 100:.0f}% ({result.method})[/]"
39
+ )
40
+ console.print(Panel(summary_text, title="[bold cyan]⚡ LATENCY BENCHMARK RESULT[/]", border_style="cyan", box=box.ROUNDED))
41
+
42
+ # 2. Per-Stage Latency Breakdown Table
43
+ t = Table(box=box.SIMPLE_HEAD, header_style="bold cyan")
44
+ t.add_column("Pipeline Stage", width=28)
45
+ t.add_column("Observed Delay", justify="right", width=16)
46
+ t.add_column("Description")
47
+
48
+ bd = result.breakdown
49
+ t.add_row("1. Input Hardware Buffer", f"{bd.input_hardware_ms:.1f} ms", f"{app.block_size} samples @ {app.sample_rate} Hz")
50
+ t.add_row("2. 11-Stage DSP & Neural AI", f"{bd.dsp_pipeline_ms:.1f} ms", "Vectorized biquad SOS + CoFi-Lite inference")
51
+ t.add_row("3. Ring Buffer Jitter Cushion", f"{bd.jitter_cushion_ms:.1f} ms", f"{app.jitter_blocks} blocks cushion (prevents crackle)")
52
+ t.add_row("4. Output Hardware Buffer", f"{bd.output_hardware_ms:.1f} ms", f"Virtual cable / headphone DAC render block")
53
+ t.add_section()
54
+ t.add_row("[bold]Total Pipeline Estimate[/]", f"[bold]{bd.total_estimated_ms:.1f} ms[/]", "[bold]Direct one-way mouth-to-Discord latency[/]")
55
+
56
+ console.print(t)
57
+
58
+ # 3. Actionable Guidance
59
+ console.print("[bold cyan]Actionable Guidance & Tuning:[/\n]")
60
+ for rec in result.recommendations:
61
+ console.print(f" [dim]•[/] {rec}")
62
+ console.print()
63
+
64
+ return 0
65
+
@@ -0,0 +1,321 @@
1
+ """`run` and `loopback`: the live dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import threading
6
+ import time
7
+
8
+ from rich.live import Live
9
+
10
+ from ..audio.engine import AudioEngine, SinkSpec
11
+ from ..config import AppConfig
12
+ from ..presets import list_presets
13
+ from ..profile_store import save_profile
14
+ from ..ui.dashboard import LiveContext, render_dashboard
15
+ from ..ui.hotkeys import GlobalHotkeyManager
16
+ from ..ui.keys import KeyReader
17
+ from ..ui.theme import console
18
+ from ..ui.tray import SystemTrayDaemon
19
+ from .common import make_chain, resolve_devices
20
+
21
+ KEYS = [
22
+ ("b", "A/B bypass"), ("x", "mute"), ("l", "monitor"), ("p", "preset"),
23
+ ("m", "model"), ("c", "calibrate"), ("+/-", "denoise"), ("[ ]", "gain"), ("q", "quit"),
24
+ ]
25
+
26
+
27
+ def run_live(
28
+ app: AppConfig,
29
+ *,
30
+ preset: str | None = None,
31
+ loopback: bool = False,
32
+ monitor: bool = False,
33
+ mic=None,
34
+ cable=None,
35
+ monitor_dev=None,
36
+ model: str | None = None,
37
+ tray: bool = False,
38
+ no_tray: bool = False,
39
+ ) -> None:
40
+ preset = preset or app.preset
41
+ model_override = model or app.denoise_model
42
+ devs = resolve_devices(
43
+ app,
44
+ need_cable=not loopback,
45
+ want_monitor=loopback or monitor,
46
+ mic_override=mic,
47
+ cable_override=cable,
48
+ monitor_override=monitor_dev,
49
+ )
50
+ chain, has_profile = make_chain(app, preset, model_override=model_override)
51
+
52
+ sinks: list[SinkSpec] = []
53
+ if devs.cable:
54
+ sinks.append(SinkSpec("cable", devs.cable))
55
+ if devs.monitor:
56
+ sinks.append(SinkSpec("monitor", devs.monitor, enabled=True))
57
+ engine = AudioEngine(
58
+ chain=chain,
59
+ input_device=devs.mic,
60
+ sinks=sinks,
61
+ input_channel=app.input_channel,
62
+ sample_rate=app.sample_rate,
63
+ block_size=app.block_size,
64
+ jitter_blocks=app.jitter_blocks,
65
+ play_cues=app.hotkeys.play_cues,
66
+ cue_volume=app.hotkeys.cue_volume,
67
+ aec_reference_device=app.aec_reference_device,
68
+ )
69
+ names = [p.name for p in list_presets()]
70
+ ctx = LiveContext(
71
+ title="LOOPBACK TEST" if loopback else "MIC ENHANCER",
72
+ preset=preset,
73
+ input_name=devs.mic.name,
74
+ cable_name=devs.cable.name if devs.cable else None,
75
+ monitor_name=devs.monitor.name if devs.monitor else None,
76
+ sample_rate=app.sample_rate,
77
+ block_size=app.block_size,
78
+ centers=engine.spectrum_range,
79
+ has_profile=has_profile,
80
+ keys=KEYS if not loopback else [k for k in KEYS if k[0] not in ("x", "l")],
81
+ )
82
+
83
+ def flash(msg: str, secs: float = 2.5) -> None:
84
+ ctx.message, ctx.message_until = msg, time.monotonic() + secs
85
+
86
+ exit_event = threading.Event()
87
+ tray_daemon: SystemTrayDaemon | None = None
88
+
89
+ def _on_toggle_mute():
90
+ is_muted = engine.toggle_mute()
91
+ if tray_daemon:
92
+ tray_daemon.update_icon()
93
+ flash("MIC MUTED" if is_muted else "Mic live")
94
+
95
+ def _on_set_mute(val: bool):
96
+ engine.set_muted(val)
97
+ if tray_daemon:
98
+ tray_daemon.update_icon()
99
+ flash("MIC MUTED" if val else "Mic live")
100
+
101
+ def _on_toggle_bypass():
102
+ is_bp = engine.toggle_bypass()
103
+ if tray_daemon:
104
+ tray_daemon.update_icon()
105
+ flash("BYPASS - raw mic" if is_bp else "PROCESSED")
106
+
107
+ def _on_switch_preset(p_name: str):
108
+ ctx.preset = p_name
109
+ new_chain, ctx.has_profile = make_chain(app, p_name, model_override=model_override)
110
+ engine.set_chain(new_chain)
111
+ flash(f"Preset: {p_name}")
112
+
113
+ def _on_switch_model(m_type: str):
114
+ dn = engine.chain.stage("denoise")
115
+ from ..dsp.cofilite import CoFiLiteDenoiser
116
+ from ..dsp.denoise import SpectralDenoiser
117
+
118
+ if m_type == "spectral":
119
+ new_dn = SpectralDenoiser(
120
+ app.sample_rate,
121
+ getattr(dn, "strength", 1.0),
122
+ getattr(dn, "max_reduction_db", 24.0),
123
+ enabled=getattr(dn, "enabled", True),
124
+ )
125
+ engine.chain.stages = [new_dn if s.name == "denoise" else s for s in engine.chain.stages]
126
+ for s in engine.sinks.values():
127
+ s.notify_pipeline_change()
128
+ flash("Swapped denoiser: Spectral Wiener (classical)")
129
+ else:
130
+ try:
131
+ new_dn = CoFiLiteDenoiser(
132
+ app.sample_rate,
133
+ getattr(dn, "strength", 1.0),
134
+ getattr(dn, "max_reduction_db", 24.0),
135
+ model_path=model_override,
136
+ )
137
+ engine.chain.stages = [new_dn if s.name == "denoise" else s for s in engine.chain.stages]
138
+ for s in engine.sinks.values():
139
+ s.notify_pipeline_change()
140
+ flash(f"Swapped denoiser: CoFi-Lite ({new_dn.model_name})")
141
+ except Exception as e:
142
+ flash(f"CoFi-Lite load error: {e}", 3.0)
143
+
144
+ def _on_settings_applied(updated_app: AppConfig) -> None:
145
+ engine.cues.set_enabled(updated_app.hotkeys.play_cues)
146
+ engine.cues.set_volume(updated_app.hotkeys.cue_volume)
147
+ if updated_app.preset != ctx.preset:
148
+ _on_switch_preset(updated_app.preset)
149
+ else:
150
+ _on_switch_preset(ctx.preset)
151
+ flash("Applied settings from GUI")
152
+
153
+ # Initialize global hotkeys
154
+ hotkeys = GlobalHotkeyManager(
155
+ app.hotkeys,
156
+ on_toggle_mute=_on_toggle_mute,
157
+ on_set_mute=_on_set_mute,
158
+ on_toggle_bypass=_on_toggle_bypass,
159
+ )
160
+ hotkeys.start()
161
+
162
+ # Initialize system tray daemon (unless disabled)
163
+ enable_tray = not no_tray and (tray or app.hotkeys.start_in_tray or os.name == "nt")
164
+ if enable_tray:
165
+ tray_daemon = SystemTrayDaemon(
166
+ app=app,
167
+ engine=engine,
168
+ on_exit=exit_event.set,
169
+ on_switch_preset=_on_switch_preset,
170
+ on_switch_model=_on_switch_model,
171
+ )
172
+ tray_daemon.start()
173
+
174
+ # If run in pure tray background mode, run headless
175
+ if tray and not loopback:
176
+ console.print("[bold cyan]✔ Micforge is running in background system tray mode.[/]")
177
+ console.print(" [dim]Right-click the microphone icon in your taskbar for controls, or use your hotkeys.[/]")
178
+ if tray_daemon:
179
+ tray_daemon.toggle_console()
180
+ with engine:
181
+ try:
182
+ while not exit_event.is_set():
183
+ time.sleep(0.3)
184
+ except KeyboardInterrupt:
185
+ pass
186
+ hotkeys.stop()
187
+ if tray_daemon:
188
+ tray_daemon.stop()
189
+ return
190
+
191
+ if loopback:
192
+ flash("Use headphones, or you will get feedback. Press b to A/B raw vs processed.", 6.0)
193
+ elif has_profile:
194
+ flash("Loaded your saved room-noise profile.", 3.0)
195
+
196
+ is_cmd = os.name == "nt" and "WT_SESSION" not in os.environ and "TERM_PROGRAM" not in os.environ
197
+ # 10 FPS on cmd.exe avoids console buffer saturation and eliminates reprint stutter
198
+ refresh_interval = 0.10 if is_cmd else 0.07
199
+ last_render = 0.0
200
+
201
+ # Collect startup/parsing garbage, freeze runtime objects, and tune GC thresholds
202
+ import gc
203
+ try:
204
+ gc.collect()
205
+ gc.freeze()
206
+ gc.set_threshold(100000, 100, 100)
207
+ except Exception:
208
+ pass
209
+
210
+ try:
211
+ with engine, KeyReader() as keys, Live(console=console, screen=True, auto_refresh=False) as live:
212
+ while not exit_event.is_set():
213
+ now = time.monotonic()
214
+ cal = engine.pop_calibration()
215
+ if cal is not None:
216
+ if cal.psd is not None:
217
+ save_profile(cal.psd, cal.sample_rate, cal.noise_db, cal.gate_threshold_db)
218
+ flash(f"Calibrated: room noise {cal.noise_db:.1f} dBFS, gate set to {cal.gate_threshold_db:.1f} dB", 5.0)
219
+
220
+ should_refresh = (now - last_render >= refresh_interval) or (cal is not None)
221
+ if should_refresh:
222
+ snap = engine.snapshot()
223
+ live.update(render_dashboard(snap, ctx, console.width, now), refresh=True)
224
+ last_render = now
225
+
226
+ k = keys.poll(0.02)
227
+ if k is None:
228
+ continue
229
+ k = k.lower() if k.isalpha() else k
230
+ if k in ("q", "\x03"):
231
+ break
232
+ if k in ("\t", "tab"):
233
+ ctx.hud_mode = not ctx.hud_mode
234
+ flash("Mode: Gamer HUD" if ctx.hud_mode else "Mode: Studio Inspector")
235
+ elif k in ("1", "2", "3", "4", "5", "6"):
236
+ idx = int(k) - 1
237
+ if 0 <= idx < len(names):
238
+ _on_switch_preset(names[idx])
239
+ elif k == "e":
240
+ aec = engine.chain.stage("aec")
241
+ if aec is not None:
242
+ aec.enabled = not getattr(aec, "enabled", True)
243
+ flash(f"AEC Echo Cancellation: {'ON' if aec.enabled else 'OFF'}")
244
+ elif k == "w":
245
+ prox = engine.chain.stage("proximity")
246
+ if prox is not None:
247
+ cur = getattr(prox, "amount", 0.5)
248
+ next_amt = 0.35 if cur < 0.2 else 0.55 if cur < 0.45 else 0.75 if cur < 0.65 else 0.0
249
+ prox.amount = next_amt
250
+ prox.enabled = (next_amt > 0.0)
251
+ flash(f"Voice Proximity Body: {int(next_amt * 100)}%" if next_amt > 0 else "Voice Proximity: OFF")
252
+ elif k == "v":
253
+ dv = engine.chain.stage("deverb")
254
+ if dv is not None:
255
+ cur = getattr(dv, "amount", 0.5)
256
+ next_amt = 0.30 if cur < 0.2 else 0.50 if cur < 0.40 else 0.70 if cur < 0.60 else 0.0
257
+ dv.amount = next_amt
258
+ dv.enabled = (next_amt > 0.0)
259
+ flash(f"Room De-Verb: {int(next_amt * 100)}%" if next_amt > 0 else "Room De-Verb: OFF")
260
+ elif k == "g":
261
+ gate = engine.chain.stage("gate")
262
+ if gate is not None:
263
+ cur = getattr(gate, "threshold_db", -48.0)
264
+ next_th = -48.0 if cur <= -50.0 else -44.0 if cur <= -46.0 else -40.0 if cur <= -42.0 else -36.0 if cur <= -38.0 else -52.0
265
+ gate.threshold_db = next_th
266
+ flash(f"Noise Gate Threshold: {next_th:.0f} dBFS")
267
+ elif k == "s":
268
+ from ..ui.settings_gui import open_settings_window
269
+ open_settings_window(app, _on_settings_applied)
270
+ flash("Opened MicForge Control Center GUI")
271
+ elif k in ("h", "?"):
272
+ ctx.show_help = not ctx.show_help
273
+ elif k == "b":
274
+ _on_toggle_bypass()
275
+ elif k == "x" and not loopback:
276
+ _on_toggle_mute()
277
+ elif k == "l" and not loopback:
278
+ if not engine.monitor_available:
279
+ flash("No monitor device. Restart with --monitor, or run `micforge setup`.", 4.0)
280
+ else:
281
+ flash("Monitor ON (use headphones!)" if engine.toggle_monitor() else "Monitor off")
282
+ elif k == "p":
283
+ i = names.index(ctx.preset) if ctx.preset in names else -1
284
+ _on_switch_preset(names[(i + 1) % len(names)])
285
+ elif k == "m":
286
+ dn = engine.chain.stage("denoise")
287
+ from ..dsp.cofilite import CoFiLiteDenoiser
288
+ _on_switch_model("spectral" if isinstance(dn, CoFiLiteDenoiser) else "cofilite")
289
+ elif k == "c":
290
+ engine.begin_calibration(3.0)
291
+ elif k in ("+", "="):
292
+ dn = engine.chain.stage("denoise")
293
+ if dn is not None:
294
+ dn.set_strength(dn.strength + 0.1) # type: ignore[attr-defined]
295
+ dn.enabled = True
296
+ flash(f"Denoise strength {dn.strength:.1f}") # type: ignore[attr-defined]
297
+ elif k in ("-", "_"):
298
+ dn = engine.chain.stage("denoise")
299
+ if dn is not None:
300
+ dn.set_strength(dn.strength - 0.1) # type: ignore[attr-defined]
301
+ flash(f"Denoise strength {dn.strength:.1f}") # type: ignore[attr-defined]
302
+ elif k == "]":
303
+ engine.chain.set_input_gain_db(engine.chain.input_gain_db + 1.0)
304
+ flash(f"Input gain {engine.chain.input_gain_db:+.0f} dB")
305
+ elif k == "[":
306
+ engine.chain.set_input_gain_db(engine.chain.input_gain_db - 1.0)
307
+ flash(f"Input gain {engine.chain.input_gain_db:+.0f} dB")
308
+
309
+ # Force immediate UI update after key press
310
+ snap = engine.snapshot()
311
+ live.update(render_dashboard(snap, ctx, console.width, time.monotonic()), refresh=True)
312
+ last_render = time.monotonic()
313
+ finally:
314
+ hotkeys.stop()
315
+ if tray_daemon:
316
+ tray_daemon.stop()
317
+
318
+ if ctx.preset != app.preset:
319
+ app.preset = ctx.preset
320
+ app.save()
321
+ console.print(f"[dim]Saved preset '{ctx.preset}' as default.[/]")
@@ -0,0 +1,142 @@
1
+ """Command-line preset management for MicForge."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from rich import box
8
+ from rich.table import Table
9
+
10
+ from ..config import AppConfig, ChainConfig
11
+ from ..errors import ConfigError
12
+ from ..presets import (
13
+ BUILTIN,
14
+ clone_preset,
15
+ delete_preset,
16
+ export_preset,
17
+ get_preset,
18
+ import_preset,
19
+ list_presets,
20
+ save_preset,
21
+ user_preset_dir,
22
+ )
23
+ from ..ui.theme import console
24
+
25
+
26
+ def _format_summary(cfg: ChainConfig) -> str:
27
+ """Format short summary of enabled stages in a preset."""
28
+ parts = []
29
+ if cfg.aec.enabled:
30
+ parts.append("AEC")
31
+ if cfg.denoise.enabled:
32
+ parts.append(f"Denoise({int(cfg.denoise.strength * 100)}%)")
33
+ if cfg.deverb.enabled:
34
+ parts.append(f"DeVerb({int(cfg.deverb.amount * 100)}%)")
35
+ if cfg.proximity.enabled:
36
+ parts.append(f"Body({int(cfg.proximity.amount * 100)}%)")
37
+ if cfg.gate.enabled:
38
+ parts.append(f"Gate({int(cfg.gate.threshold_db)}dB)")
39
+ if cfg.eq_enabled and cfg.eq:
40
+ parts.append(f"EQ({len(cfg.eq)}b)")
41
+ if cfg.compressor.enabled:
42
+ parts.append("Comp")
43
+ return ", ".join(parts) if parts else "Bypass"
44
+
45
+
46
+ def run_preset_list(app: AppConfig) -> None:
47
+ """List all available built-in and custom user presets."""
48
+ presets = list_presets()
49
+ t = Table(box=box.ROUNDED, header_style="bold cyan")
50
+ t.add_column("Status", justify="center", width=8)
51
+ t.add_column("Preset Name", style="bold white")
52
+ t.add_column("Type", justify="center")
53
+ t.add_column("DSP Stages Active", style="dim")
54
+ t.add_column("Description")
55
+
56
+ active_name = app.preset
57
+
58
+ for p in presets:
59
+ is_active = (p.name == active_name)
60
+ status_badge = "[bold green]● Active[/]" if is_active else "[dim]○[/]"
61
+ type_badge = "[green]Built-in[/]" if p.builtin else "[cyan]Custom[/]"
62
+
63
+ try:
64
+ cfg = get_preset(p.name)
65
+ summary = _format_summary(cfg)
66
+ except Exception:
67
+ summary = "Error reading"
68
+
69
+ t.add_row(status_badge, p.name, type_badge, summary, p.description)
70
+
71
+ console.print(t)
72
+ console.print(f"[dim]Presets directory: {user_preset_dir()}[/]")
73
+ console.print("[dim]Use `micforge preset save <name>` or `micforge live -p <name>`[/]")
74
+
75
+
76
+ def run_preset_show(name: str) -> None:
77
+ """Show detailed JSON configuration for a preset."""
78
+ try:
79
+ cfg = get_preset(name)
80
+ except ConfigError as exc:
81
+ console.print(f"[bold red]✖ {exc}[/]")
82
+ return
83
+
84
+ console.print(f"[bold cyan]Preset:[/] [bold green]{name}[/]")
85
+ console.print_json(json.dumps(cfg.to_dict()))
86
+
87
+
88
+ def run_preset_save(
89
+ app: AppConfig,
90
+ name: str,
91
+ from_preset: str | None = None,
92
+ description: str = "",
93
+ force: bool = False,
94
+ ) -> None:
95
+ """Save a preset (derived from current active config or an existing preset)."""
96
+ base_name = from_preset or app.preset
97
+ cfg = get_preset(base_name)
98
+ desc = description or f"Custom preset based on {base_name}"
99
+
100
+ path = save_preset(name, cfg, overwrite=force, description=desc)
101
+ console.print(f"[bold green]✔ Saved custom preset:[/] [bold white]{name}[/]")
102
+ console.print(f" [dim]Location:[/] {path}")
103
+ console.print(f" [dim]Run it with:[/] [cyan]micforge live -p {name}[/]")
104
+
105
+
106
+ def run_preset_copy(source: str, target: str, force: bool = False) -> None:
107
+ """Copy an existing preset to a new custom preset."""
108
+ clone_preset(source, target, overwrite=force)
109
+ console.print(f"[bold green]✔ Duplicated preset:[/] [cyan]{source}[/] → [bold white]{target}[/]")
110
+ console.print(f" [dim]Run it with:[/] [cyan]micforge live -p {target}[/]")
111
+
112
+
113
+ def run_preset_delete(name: str, force: bool = False) -> None:
114
+ """Delete a custom user preset."""
115
+ if name in BUILTIN:
116
+ console.print(f"[bold red]✖ Cannot delete built-in preset '{name}'. Built-ins are permanent.[/]")
117
+ return
118
+
119
+ if not force:
120
+ # Prompt for confirmation in console
121
+ from rich.prompt import Confirm
122
+ if not Confirm.ask(f"Are you sure you want to permanently delete custom preset '{name}'?"):
123
+ console.print("[dim]Cancelled.[/]")
124
+ return
125
+
126
+ delete_preset(name)
127
+ console.print(f"[bold green]✔ Deleted preset:[/] [white]{name}[/]")
128
+
129
+
130
+ def run_preset_export(name: str, target_path: str | None = None) -> None:
131
+ """Export a preset to a portable JSON file."""
132
+ dest = Path(target_path) if target_path else Path(f"{name}_preset.json")
133
+ out = export_preset(name, dest)
134
+ console.print(f"[bold green]✔ Exported preset '{name}' to:[/] [cyan]{out}[/]")
135
+
136
+
137
+ def run_preset_import(source_path: str, name: str | None = None, force: bool = False) -> None:
138
+ """Import a preset JSON file."""
139
+ preset_name = import_preset(source_path, name=name, overwrite=force)
140
+ console.print(f"[bold green]✔ Successfully imported preset:[/] [bold white]{preset_name}[/]")
141
+ console.print(f" [dim]Run it with:[/] [cyan]micforge live -p {preset_name}[/]")
142
+
@@ -0,0 +1,98 @@
1
+ """`test`: record -> process -> compare -> play back raw vs processed."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+
7
+ import numpy as np
8
+ from rich.live import Live
9
+ from rich.prompt import Prompt
10
+ from rich.text import Text
11
+
12
+ from ..analysis.stats import analyze, band_levels_db, diagnose
13
+ from ..audio.devices import find_device, list_devices
14
+ from ..audio.fileio import write_wav
15
+ from ..audio.recorder import Recorder, play
16
+ from ..config import AppConfig, config_dir
17
+ from ..dsp.chain import build_chain, process_offline
18
+ from ..errors import MicforgeError
19
+ from ..presets import get_preset
20
+ from ..profile_store import load_profile
21
+ from ..ui.report import full_report
22
+ from ..ui.theme import console
23
+ from ..ui.widgets import fmt_db, level_bar
24
+
25
+ SILENCE_S = 2.0
26
+
27
+
28
+ def record_clip(app: AppConfig, mic, seconds: float) -> np.ndarray:
29
+ pool = list_devices(all_apis=True)
30
+ dev = find_device(mic if mic is not None else app.input_device, "input", pool)
31
+ rec = Recorder(dev, app.sample_rate, app.input_channel, app.block_size)
32
+ console.print(f"[dim]Mic: {dev.name}[/]")
33
+ rec.start()
34
+ is_cmd = os.name == "nt" and "WT_SESSION" not in os.environ and "TERM_PROGRAM" not in os.environ
35
+ fps = 10 if is_cmd else 20
36
+ try:
37
+ with Live(console=console, refresh_per_second=fps, transient=True) as live:
38
+ while rec.seconds < seconds:
39
+ lv = rec.level
40
+ phase = ("[yellow]Stay SILENT[/] (measuring room noise)" if rec.seconds < SILENCE_S
41
+ else "[bold green]Now SPEAK[/] naturally, like on a call")
42
+ t = Text.from_markup(f"{phase} {rec.seconds:4.1f}/{seconds:.0f}s\n")
43
+ t.append_text(level_bar(lv.peak_db, lv.hold_db, max(20, console.width - 20)))
44
+ t.append(f" {fmt_db(lv.peak_db)} dB")
45
+ live.update(t)
46
+ time.sleep(0.04)
47
+ finally:
48
+ x = rec.stop()
49
+ return x
50
+
51
+
52
+ def run_test(app: AppConfig, *, preset: str | None = None, seconds: float = 8.0, mic=None, out=None) -> None:
53
+ preset = preset or app.preset
54
+ pool = list_devices(all_apis=True)
55
+ speaker = find_device(out if out is not None else app.monitor_device, "output", pool)
56
+
57
+ raw = record_clip(app, mic, seconds)
58
+ if raw.shape[0] < app.sample_rate:
59
+ raise MicforgeError("Recording failed (no audio captured).")
60
+ sr = app.sample_rate
61
+
62
+ # Learn this room's noise from the silent lead-in, so the test reflects what calibration would give.
63
+ cfg = get_preset(preset)
64
+ chain = build_chain(cfg, sr, model_override=app.denoise_model)
65
+ dn = chain.stage("denoise")
66
+ try:
67
+ dn.learn(raw[: int(SILENCE_S * sr)]) # type: ignore[union-attr]
68
+ except ValueError:
69
+ prof = load_profile(sr)
70
+ if prof:
71
+ dn.set_profile(prof[0]) # type: ignore[union-attr]
72
+ proc = process_offline(chain, raw, app.block_size)
73
+
74
+ before, after = analyze(raw, sr), analyze(proc, sr)
75
+ console.print(full_report(before, after, band_levels_db(raw, sr), band_levels_db(proc, sr), diagnose(after)))
76
+
77
+ clips = {"1": ("raw", raw), "2": (f"processed ({preset})", proc)}
78
+ console.print("\n[dim]Playback via[/] " + speaker.name)
79
+ while True:
80
+ choice = Prompt.ask(
81
+ "\n[bold cyan]1[/] raw [bold cyan]2[/] processed [bold cyan]3[/] A/B [bold cyan]s[/] save wavs [bold cyan]q[/] done",
82
+ choices=["1", "2", "3", "s", "q"], default="3", console=console,
83
+ )
84
+ if choice == "q":
85
+ break
86
+ if choice == "s":
87
+ d = config_dir() / "recordings"
88
+ stamp = time.strftime("%Y%m%d-%H%M%S")
89
+ write_wav(d / f"{stamp}-raw.wav", raw, sr)
90
+ write_wav(d / f"{stamp}-{preset}.wav", proc, sr)
91
+ console.print(f"[green]Saved to {d}[/]")
92
+ continue
93
+ order = ["1", "2"] if choice == "3" else [choice]
94
+ for k in order:
95
+ label, data = clips[k]
96
+ console.print(f" ▶ {label}")
97
+ play(data, sr, speaker)
98
+ time.sleep(0.25)