micforge 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- micforge/__init__.py +3 -0
- micforge/__main__.py +4 -0
- micforge/analysis/__init__.py +0 -0
- micforge/analysis/stats.py +133 -0
- micforge/audio/__init__.py +0 -0
- micforge/audio/backend.py +32 -0
- micforge/audio/cues.py +110 -0
- micforge/audio/device_watch.py +237 -0
- micforge/audio/devices.py +160 -0
- micforge/audio/engine.py +746 -0
- micforge/audio/fileio.py +45 -0
- micforge/audio/latency.py +169 -0
- micforge/audio/loopback.py +113 -0
- micforge/audio/metrics.py +134 -0
- micforge/audio/noise_profile.py +250 -0
- micforge/audio/recorder.py +100 -0
- micforge/audio/ring.py +89 -0
- micforge/cli.py +293 -0
- micforge/commands/__init__.py +0 -0
- micforge/commands/calibrate.py +47 -0
- micforge/commands/common.py +69 -0
- micforge/commands/devices_cmd.py +86 -0
- micforge/commands/diagnostics_cmd.py +68 -0
- micforge/commands/doctor.py +127 -0
- micforge/commands/files.py +48 -0
- micforge/commands/help_cmd.py +449 -0
- micforge/commands/latency_test.py +65 -0
- micforge/commands/live.py +321 -0
- micforge/commands/preset_cmd.py +142 -0
- micforge/commands/test_mic.py +98 -0
- micforge/commands/wizard.py +70 -0
- micforge/config.py +279 -0
- micforge/diagnostics.py +308 -0
- micforge/dsp/__init__.py +0 -0
- micforge/dsp/aec.py +388 -0
- micforge/dsp/base.py +30 -0
- micforge/dsp/biquad.py +144 -0
- micforge/dsp/chain.py +153 -0
- micforge/dsp/cofilite.py +427 -0
- micforge/dsp/compressor.py +109 -0
- micforge/dsp/deesser.py +64 -0
- micforge/dsp/denoise.py +181 -0
- micforge/dsp/deverb.py +227 -0
- micforge/dsp/export_cofilite.py +111 -0
- micforge/dsp/gate.py +76 -0
- micforge/dsp/leveler.py +74 -0
- micforge/dsp/proximity.py +164 -0
- micforge/dsp/util.py +80 -0
- micforge/errors.py +25 -0
- micforge/logging_setup.py +25 -0
- micforge/models/cofilite.onnx +0 -0
- micforge/presets.py +284 -0
- micforge/profile_store.py +41 -0
- micforge/ui/__init__.py +0 -0
- micforge/ui/dashboard.py +573 -0
- micforge/ui/hotkeys.py +169 -0
- micforge/ui/keys.py +64 -0
- micforge/ui/report.py +88 -0
- micforge/ui/settings_gui.py +899 -0
- micforge/ui/theme.py +42 -0
- micforge/ui/tray.py +276 -0
- micforge/ui/tuning.py +138 -0
- micforge/ui/widgets.py +109 -0
- micforge/util/autostart.py +56 -0
- micforge-1.3.0.dist-info/METADATA +334 -0
- micforge-1.3.0.dist-info/RECORD +70 -0
- micforge-1.3.0.dist-info/WHEEL +5 -0
- micforge-1.3.0.dist-info/entry_points.txt +2 -0
- micforge-1.3.0.dist-info/licenses/LICENSE +22 -0
- micforge-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|