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/ui/theme.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared console + palette."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
if sys.platform == "win32":
|
|
7
|
+
# Enable UTF-8 encoding and native Virtual Terminal (VT100) processing for Windows CMD / conhost
|
|
8
|
+
try:
|
|
9
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
10
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
11
|
+
except Exception:
|
|
12
|
+
pass
|
|
13
|
+
try:
|
|
14
|
+
import ctypes
|
|
15
|
+
|
|
16
|
+
kernel32 = ctypes.windll.kernel32
|
|
17
|
+
kernel32.SetConsoleOutputCP(65001)
|
|
18
|
+
kernel32.SetConsoleCP(65001)
|
|
19
|
+
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
|
20
|
+
mode = ctypes.c_ulong()
|
|
21
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
22
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.theme import Theme
|
|
28
|
+
|
|
29
|
+
THEME = Theme(
|
|
30
|
+
{
|
|
31
|
+
"accent": "bold cyan",
|
|
32
|
+
"dim": "grey50",
|
|
33
|
+
"ok": "green",
|
|
34
|
+
"warn": "yellow",
|
|
35
|
+
"bad": "bold red",
|
|
36
|
+
"key": "bold black on cyan",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
console = Console(theme=THEME, highlight=False, legacy_windows=False if sys.platform == "win32" else None)
|
|
41
|
+
|
|
42
|
+
LEVEL_ICON = {"ok": "[ok]✔[/]", "warn": "[warn]![/]", "bad": "[bad]✖[/]"}
|
micforge/ui/tray.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""System Tray Daemon for Micforge.
|
|
2
|
+
|
|
3
|
+
Provides a persistent icon in the Windows taskbar with:
|
|
4
|
+
* Live dynamic icon (cyan when active, gray with red slash when muted, amber when bypassed)
|
|
5
|
+
* Instant mute / bypass controls
|
|
6
|
+
* Presets and CoFi-Lite / Spectral model switcher
|
|
7
|
+
* Quick access to Settings & Hotkeys GUI
|
|
8
|
+
* Windows autostart toggle
|
|
9
|
+
* Hide / Restore console window
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ctypes
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from PIL import Image, ImageDraw
|
|
20
|
+
|
|
21
|
+
from .. import __version__
|
|
22
|
+
from ..audio.engine import AudioEngine
|
|
23
|
+
from ..config import AppConfig
|
|
24
|
+
from ..presets import list_presets
|
|
25
|
+
from ..util.autostart import is_autostart_enabled, set_autostart
|
|
26
|
+
from .settings_gui import open_settings_window
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("micforge.tray")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_patch_pystray_win32() -> None:
|
|
32
|
+
"""Patch pystray's ChangeWindowMessageFilterEx to gracefully handle Windows UIPI security restrictions."""
|
|
33
|
+
if os.name != "nt":
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
import pystray._util.win32 as w32util
|
|
37
|
+
|
|
38
|
+
orig = getattr(w32util, "ChangeWindowMessageFilterEx", None)
|
|
39
|
+
if orig is not None and not getattr(orig, "_micforge_patched", False):
|
|
40
|
+
|
|
41
|
+
def _safe_filter(*args, **kwargs):
|
|
42
|
+
try:
|
|
43
|
+
return orig(*args, **kwargs)
|
|
44
|
+
except Exception:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
_safe_filter._micforge_patched = True # type: ignore[attr-defined]
|
|
48
|
+
w32util.ChangeWindowMessageFilterEx = _safe_filter
|
|
49
|
+
except Exception as exc: # noqa: BLE001
|
|
50
|
+
log.debug("pystray win32 patch failed: %s", exc)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def generate_tray_icon(state: str = "active") -> Image.Image:
|
|
54
|
+
"""Procedurally draw a crisp 64x64 RGBA microphone icon."""
|
|
55
|
+
im = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
|
56
|
+
d = ImageDraw.Draw(im)
|
|
57
|
+
|
|
58
|
+
if state == "muted":
|
|
59
|
+
color = (150, 150, 155)
|
|
60
|
+
elif state == "bypass":
|
|
61
|
+
color = (250, 204, 21) # Amber
|
|
62
|
+
else:
|
|
63
|
+
color = (6, 182, 212) # Cyan
|
|
64
|
+
|
|
65
|
+
# 1. Microphone capsule
|
|
66
|
+
d.rounded_rectangle([24, 12, 40, 36], radius=7, fill=color)
|
|
67
|
+
|
|
68
|
+
# 2. Cradle arc
|
|
69
|
+
d.arc([16, 20, 48, 44], start=0, end=180, fill=color, width=3)
|
|
70
|
+
|
|
71
|
+
# 3. Base stem and foot
|
|
72
|
+
d.line([32, 44, 32, 54], fill=color, width=3)
|
|
73
|
+
d.line([20, 54, 44, 54], fill=color, width=3)
|
|
74
|
+
|
|
75
|
+
# 4. If muted, add prominent red diagonal slash and indicator dot
|
|
76
|
+
if state == "muted":
|
|
77
|
+
d.line([14, 14, 50, 50], fill=(239, 68, 68), width=5)
|
|
78
|
+
d.ellipse([44, 10, 56, 22], fill=(239, 68, 68))
|
|
79
|
+
|
|
80
|
+
return im
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SystemTrayDaemon:
|
|
84
|
+
"""Manages the Windows system tray lifecycle, icon updates, and menu actions."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
app: AppConfig,
|
|
89
|
+
engine: AudioEngine,
|
|
90
|
+
on_exit: Callable[[], None],
|
|
91
|
+
on_switch_preset: Callable[[str], None] | None = None,
|
|
92
|
+
on_switch_model: Callable[[str], None] | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.app = app
|
|
95
|
+
self.engine = engine
|
|
96
|
+
self.on_exit = on_exit
|
|
97
|
+
self.on_switch_preset = on_switch_preset
|
|
98
|
+
self.on_switch_model = on_switch_model
|
|
99
|
+
|
|
100
|
+
self._icon: Any = None
|
|
101
|
+
self._thread: threading.Thread | None = None
|
|
102
|
+
self._running = False
|
|
103
|
+
self._console_visible = True
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def current_state(self) -> str:
|
|
107
|
+
if self.engine.muted:
|
|
108
|
+
return "muted"
|
|
109
|
+
if self.engine.bypass:
|
|
110
|
+
return "bypass"
|
|
111
|
+
return "active"
|
|
112
|
+
|
|
113
|
+
def update_icon(self) -> None:
|
|
114
|
+
"""Refresh tray icon image to reflect current mute/bypass state."""
|
|
115
|
+
if self._icon is not None:
|
|
116
|
+
try:
|
|
117
|
+
self._icon.icon = generate_tray_icon(self.current_state)
|
|
118
|
+
self._icon.title = f"Micforge v{__version__} ({self.current_state.capitalize()})"
|
|
119
|
+
except Exception as exc: # noqa: BLE001
|
|
120
|
+
log.debug("Tray icon refresh failed: %s", exc)
|
|
121
|
+
|
|
122
|
+
def start(self) -> None:
|
|
123
|
+
"""Start the system tray icon in a dedicated daemon thread."""
|
|
124
|
+
try:
|
|
125
|
+
import pystray
|
|
126
|
+
except ImportError:
|
|
127
|
+
log.warning("pystray not installed; running without system tray.")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self._running = True
|
|
131
|
+
self._thread = threading.Thread(target=self._run_tray, daemon=True)
|
|
132
|
+
self._thread.start()
|
|
133
|
+
|
|
134
|
+
def stop(self) -> None:
|
|
135
|
+
self._running = False
|
|
136
|
+
if self._icon is not None:
|
|
137
|
+
try:
|
|
138
|
+
self._icon.stop()
|
|
139
|
+
except Exception: # noqa: BLE001
|
|
140
|
+
pass
|
|
141
|
+
self._icon = None
|
|
142
|
+
|
|
143
|
+
def toggle_console(self) -> None:
|
|
144
|
+
"""Toggle console window visibility on Windows."""
|
|
145
|
+
if os.name != "nt":
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
kernel32 = ctypes.windll.kernel32
|
|
149
|
+
user32 = ctypes.windll.user32
|
|
150
|
+
hwnd = kernel32.GetConsoleWindow()
|
|
151
|
+
if hwnd:
|
|
152
|
+
self._console_visible = not self._console_visible
|
|
153
|
+
# SW_HIDE = 0, SW_SHOW = 5
|
|
154
|
+
user32.ShowWindow(hwnd, 5 if self._console_visible else 0)
|
|
155
|
+
except Exception as exc: # noqa: BLE001
|
|
156
|
+
log.debug("Console toggle failed: %s", exc)
|
|
157
|
+
|
|
158
|
+
def _build_menu(self):
|
|
159
|
+
import pystray
|
|
160
|
+
|
|
161
|
+
# Preset submenu
|
|
162
|
+
preset_items = []
|
|
163
|
+
for p in list_presets():
|
|
164
|
+
p_name = p.name
|
|
165
|
+
|
|
166
|
+
def _make_preset_cb(name: str):
|
|
167
|
+
return lambda item: self._on_preset_click(name)
|
|
168
|
+
|
|
169
|
+
def _make_preset_checked(name: str):
|
|
170
|
+
return lambda item: self.app.preset == name
|
|
171
|
+
|
|
172
|
+
preset_items.append(
|
|
173
|
+
pystray.MenuItem(
|
|
174
|
+
p_name,
|
|
175
|
+
_make_preset_cb(p_name),
|
|
176
|
+
checked=_make_preset_checked(p_name),
|
|
177
|
+
radio=True,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Model submenu
|
|
182
|
+
def _set_cofilite(item):
|
|
183
|
+
if self.on_switch_model:
|
|
184
|
+
self.on_switch_model("cofilite")
|
|
185
|
+
|
|
186
|
+
def _set_spectral(item):
|
|
187
|
+
if self.on_switch_model:
|
|
188
|
+
self.on_switch_model("spectral")
|
|
189
|
+
|
|
190
|
+
def _is_cofilite(item):
|
|
191
|
+
dn = self.engine.chain.stage("denoise")
|
|
192
|
+
return getattr(dn, "name", "") == "denoise" and "cofilite" in getattr(dn, "model_name", "").lower()
|
|
193
|
+
|
|
194
|
+
def _is_spectral(item):
|
|
195
|
+
dn = self.engine.chain.stage("denoise")
|
|
196
|
+
return getattr(dn, "name", "") == "denoise" and "cofilite" not in getattr(dn, "model_name", "").lower()
|
|
197
|
+
|
|
198
|
+
model_items = [
|
|
199
|
+
pystray.MenuItem("CoFi-Lite Neural (High Quality, <70ms)", _set_cofilite, checked=_is_cofilite, radio=True),
|
|
200
|
+
pystray.MenuItem("Spectral Wiener (Ultra-Low Latency, <48ms)", _set_spectral, checked=_is_spectral, radio=True),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
# Top level menu
|
|
204
|
+
menu = pystray.Menu(
|
|
205
|
+
pystray.MenuItem(
|
|
206
|
+
lambda item: "✔ Muted" if self.engine.muted else "Mute Mic",
|
|
207
|
+
self._on_toggle_mute,
|
|
208
|
+
checked=lambda item: self.engine.muted,
|
|
209
|
+
),
|
|
210
|
+
pystray.MenuItem(
|
|
211
|
+
lambda item: "✔ Bypass On" if self.engine.bypass else "Bypass DSP",
|
|
212
|
+
self._on_toggle_bypass,
|
|
213
|
+
checked=lambda item: self.engine.bypass,
|
|
214
|
+
),
|
|
215
|
+
pystray.Menu.SEPARATOR,
|
|
216
|
+
pystray.MenuItem("Presets", pystray.Menu(*preset_items)),
|
|
217
|
+
pystray.MenuItem("Noise Suppression Model", pystray.Menu(*model_items)),
|
|
218
|
+
pystray.Menu.SEPARATOR,
|
|
219
|
+
pystray.MenuItem("Settings & Hotkeys...", lambda item: open_settings_window(self.app, self._on_settings_applied)),
|
|
220
|
+
pystray.MenuItem(
|
|
221
|
+
"Start on Windows Boot",
|
|
222
|
+
self._on_toggle_autostart,
|
|
223
|
+
checked=lambda item: is_autostart_enabled(),
|
|
224
|
+
),
|
|
225
|
+
pystray.MenuItem(
|
|
226
|
+
lambda item: "Hide Dashboard" if self._console_visible else "Show Dashboard",
|
|
227
|
+
lambda item: self.toggle_console(),
|
|
228
|
+
),
|
|
229
|
+
pystray.Menu.SEPARATOR,
|
|
230
|
+
pystray.MenuItem("Exit Micforge", lambda item: self.on_exit()),
|
|
231
|
+
)
|
|
232
|
+
return menu
|
|
233
|
+
|
|
234
|
+
def _run_tray(self) -> None:
|
|
235
|
+
_safe_patch_pystray_win32()
|
|
236
|
+
import pystray
|
|
237
|
+
|
|
238
|
+
initial_image = generate_tray_icon(self.current_state)
|
|
239
|
+
self._icon = pystray.Icon(
|
|
240
|
+
"micforge",
|
|
241
|
+
initial_image,
|
|
242
|
+
title=f"Micforge v{__version__} ({self.current_state.capitalize()})",
|
|
243
|
+
menu=self._build_menu(),
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
self._icon.run()
|
|
247
|
+
except Exception as exc: # noqa: BLE001
|
|
248
|
+
log.warning("System tray run exited: %s", exc)
|
|
249
|
+
|
|
250
|
+
def _on_toggle_mute(self, item=None) -> None:
|
|
251
|
+
self.engine.toggle_mute()
|
|
252
|
+
self.update_icon()
|
|
253
|
+
|
|
254
|
+
def _on_toggle_bypass(self, item=None) -> None:
|
|
255
|
+
self.engine.toggle_bypass()
|
|
256
|
+
self.update_icon()
|
|
257
|
+
|
|
258
|
+
def _on_preset_click(self, preset_name: str) -> None:
|
|
259
|
+
self.app.preset = preset_name
|
|
260
|
+
try:
|
|
261
|
+
self.app.save()
|
|
262
|
+
except Exception: # noqa: BLE001
|
|
263
|
+
pass
|
|
264
|
+
if self.on_switch_preset is not None:
|
|
265
|
+
self.on_switch_preset(preset_name)
|
|
266
|
+
|
|
267
|
+
def _on_toggle_autostart(self, item=None) -> None:
|
|
268
|
+
current = is_autostart_enabled()
|
|
269
|
+
set_autostart(not current)
|
|
270
|
+
|
|
271
|
+
def _on_settings_applied(self, updated_app: AppConfig) -> None:
|
|
272
|
+
log.info("Settings updated via GUI")
|
|
273
|
+
self.app = updated_app
|
|
274
|
+
self.engine.cues.set_enabled(updated_app.hotkeys.play_cues)
|
|
275
|
+
self.engine.cues.set_volume(updated_app.hotkeys.cue_volume)
|
|
276
|
+
|
micforge/ui/tuning.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Simple and Advanced tuning schema and macro-to-DSP mappings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..config import ChainConfig, CompressorConfig, DenoiseConfig, EQBand, GateConfig, LimiterConfig
|
|
9
|
+
from ..presets import BUILTIN, get_preset
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MacroKnobs:
|
|
14
|
+
"""Four intuitive macro knobs for casual/streamer tuning (0-100 scale)."""
|
|
15
|
+
clarity: float = 50.0 # Presence, air, sibilance control, mud cut
|
|
16
|
+
warmth: float = 50.0 # Chest resonance, proximity body, low-end richness
|
|
17
|
+
noise_removal: float = 65.0 # AI suppression depth & gate aggressiveness
|
|
18
|
+
room_cleanup: float = 40.0 # Room flutter dereverberation & rumble isolation
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
self.clamp()
|
|
22
|
+
|
|
23
|
+
def clamp(self) -> None:
|
|
24
|
+
self.clarity = float(max(0.0, min(100.0, self.clarity)))
|
|
25
|
+
self.warmth = float(max(0.0, min(100.0, self.warmth)))
|
|
26
|
+
self.noise_removal = float(max(0.0, min(100.0, self.noise_removal)))
|
|
27
|
+
self.room_cleanup = float(max(0.0, min(100.0, self.room_cleanup)))
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, float]:
|
|
30
|
+
return asdict(self)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def macro_to_chain_config(macros: MacroKnobs, base_preset_name: str | ChainConfig = "discord") -> ChainConfig:
|
|
34
|
+
"""Deterministically map 4 macro knobs (0-100) onto a full 11-stage DSP ChainConfig."""
|
|
35
|
+
macros.clamp()
|
|
36
|
+
|
|
37
|
+
# Always clone a fresh instance so built-ins are never mutated
|
|
38
|
+
if isinstance(base_preset_name, ChainConfig):
|
|
39
|
+
cfg = copy.deepcopy(base_preset_name)
|
|
40
|
+
else:
|
|
41
|
+
cfg = copy.deepcopy(get_preset(base_preset_name))
|
|
42
|
+
|
|
43
|
+
# 1. CLARITY (0 - 100)
|
|
44
|
+
# Scales: presence peak (+0 to +6 dB at 3200 Hz), air shelf (+0 to +5 dB at 9000 Hz), mud cut (-0 to -4.5 dB at 280 Hz)
|
|
45
|
+
clarity_norm = macros.clarity / 100.0
|
|
46
|
+
presence_gain = 1.0 + (clarity_norm * 4.5) # 1.0 -> 5.5 dB
|
|
47
|
+
air_gain = 1.0 + (clarity_norm * 4.0) # 1.0 -> 5.0 dB
|
|
48
|
+
mud_cut = -1.0 - (clarity_norm * 3.5) # -1.0 -> -4.5 dB
|
|
49
|
+
|
|
50
|
+
cfg.eq = [
|
|
51
|
+
EQBand("peak", 280, mud_cut, 1.1),
|
|
52
|
+
EQBand("peak", 3200, presence_gain, 0.9),
|
|
53
|
+
EQBand("highshelf", 9000, air_gain, 0.7),
|
|
54
|
+
]
|
|
55
|
+
# Sibilance taming scales with clarity to prevent harshness
|
|
56
|
+
if clarity_norm > 0.3:
|
|
57
|
+
cfg.deesser.enabled = True
|
|
58
|
+
cfg.deesser.threshold_db = -28.0 - (clarity_norm * 8.0) # -30.4 to -36.0 dBFS
|
|
59
|
+
cfg.deesser.ratio = 2.5 + (clarity_norm * 2.0)
|
|
60
|
+
else:
|
|
61
|
+
cfg.deesser.enabled = False
|
|
62
|
+
|
|
63
|
+
# 2. WARMTH (0 - 100)
|
|
64
|
+
# Scales: proximity effect amount, saturation warmth, chest resonance
|
|
65
|
+
warmth_norm = macros.warmth / 100.0
|
|
66
|
+
if warmth_norm > 0.05:
|
|
67
|
+
cfg.proximity.enabled = True
|
|
68
|
+
cfg.proximity.amount = float(np_clip(warmth_norm * 1.1, 0.0, 1.0))
|
|
69
|
+
cfg.proximity.warmth = float(np_clip(warmth_norm * 0.75, 0.0, 0.85))
|
|
70
|
+
cfg.proximity.body_hz = 105.0 + (warmth_norm * 35.0) # 105 -> 140 Hz
|
|
71
|
+
cfg.proximity.mud_cleanup = float(np_clip(0.3 + clarity_norm * 0.4, 0.0, 1.0))
|
|
72
|
+
else:
|
|
73
|
+
cfg.proximity.enabled = False
|
|
74
|
+
|
|
75
|
+
# 3. NOISE REMOVAL (0 - 100)
|
|
76
|
+
# Scales: AI suppression strength (0 to 1.0) and gate threshold
|
|
77
|
+
noise_norm = macros.noise_removal / 100.0
|
|
78
|
+
cfg.denoise.enabled = noise_norm > 0.05
|
|
79
|
+
cfg.denoise.strength = float(np_clip(noise_norm, 0.0, 1.0))
|
|
80
|
+
cfg.denoise.max_reduction_db = 14.0 + (noise_norm * 20.0) # 14 -> 34 dB
|
|
81
|
+
|
|
82
|
+
if noise_norm > 0.2:
|
|
83
|
+
cfg.gate.enabled = True
|
|
84
|
+
cfg.gate.threshold_db = -54.0 + (noise_norm * 18.0) # -50.4 -> -36.0 dBFS
|
|
85
|
+
cfg.gate.range_db = 15.0 + (noise_norm * 20.0) # 19 -> 35 dB
|
|
86
|
+
else:
|
|
87
|
+
cfg.gate.enabled = False
|
|
88
|
+
|
|
89
|
+
# 4. ROOM CLEANUP (0 - 100)
|
|
90
|
+
# Scales: room de-verb amount and highpass rumble filter
|
|
91
|
+
room_norm = macros.room_cleanup / 100.0
|
|
92
|
+
if room_norm > 0.08:
|
|
93
|
+
cfg.deverb.enabled = True
|
|
94
|
+
cfg.deverb.amount = float(np_clip(room_norm * 0.9, 0.0, 0.85))
|
|
95
|
+
cfg.deverb.room_decay = 0.25 + (room_norm * 0.5) # 0.29 -> 0.75 s
|
|
96
|
+
else:
|
|
97
|
+
cfg.deverb.enabled = False
|
|
98
|
+
|
|
99
|
+
cfg.highpass_hz = int(75 + (room_norm * 35)) # 75 -> 110 Hz
|
|
100
|
+
|
|
101
|
+
cfg.validate()
|
|
102
|
+
return cfg
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def chain_config_to_macro(cfg: ChainConfig) -> MacroKnobs:
|
|
106
|
+
"""Project a full DSP ChainConfig back into 4 approximate macro knob values (0-100)."""
|
|
107
|
+
# 1. Clarity estimate from EQ presence and air
|
|
108
|
+
eq_gains = [b.gain_db for b in cfg.eq if b.gain_db > 0]
|
|
109
|
+
clarity = 50.0
|
|
110
|
+
if eq_gains:
|
|
111
|
+
clarity = float(np_clip(np_mean(eq_gains) * 15.0, 0.0, 100.0))
|
|
112
|
+
|
|
113
|
+
# 2. Warmth estimate from proximity
|
|
114
|
+
warmth = 0.0
|
|
115
|
+
if cfg.proximity.enabled:
|
|
116
|
+
warmth = float(np_clip(cfg.proximity.amount * 100.0, 0.0, 100.0))
|
|
117
|
+
|
|
118
|
+
# 3. Noise removal estimate
|
|
119
|
+
noise = 0.0
|
|
120
|
+
if cfg.denoise.enabled:
|
|
121
|
+
noise = float(np_clip(cfg.denoise.strength * 100.0, 0.0, 100.0))
|
|
122
|
+
|
|
123
|
+
# 4. Room cleanup estimate
|
|
124
|
+
room = 0.0
|
|
125
|
+
if cfg.deverb.enabled:
|
|
126
|
+
room = float(np_clip(cfg.deverb.amount * 115.0, 0.0, 100.0))
|
|
127
|
+
|
|
128
|
+
knobs = MacroKnobs(clarity=clarity, warmth=warmth, noise_removal=noise, room_cleanup=room)
|
|
129
|
+
knobs.clamp()
|
|
130
|
+
return knobs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def np_clip(v: float, lo: float, hi: float) -> float:
|
|
134
|
+
return max(lo, min(hi, v))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def np_mean(arr: list[float]) -> float:
|
|
138
|
+
return sum(arr) / len(arr) if arr else 0.0
|
micforge/ui/widgets.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Pure rendering helpers: meters, spectrum columns, bars. No I/O, trivially testable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
BLOCKS = " ▁▂▃▄▅▆▇█"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|
13
|
+
return lo if v < lo else hi if v > hi else v
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def level_style(db: float) -> str:
|
|
17
|
+
if db >= -6.0:
|
|
18
|
+
return "bold red"
|
|
19
|
+
if db >= -18.0:
|
|
20
|
+
return "yellow"
|
|
21
|
+
return "green"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def level_bar(peak_db: float, hold_db: float, width: int, lo: float = -60.0, hi: float = 0.0) -> Text:
|
|
25
|
+
width = max(8, width)
|
|
26
|
+
filled = int(round(_clamp((peak_db - lo) / (hi - lo)) * width))
|
|
27
|
+
hold_pos = int(round(_clamp((hold_db - lo) / (hi - lo)) * width)) - 1
|
|
28
|
+
t = Text()
|
|
29
|
+
|
|
30
|
+
curr_char = ""
|
|
31
|
+
curr_style = ""
|
|
32
|
+
curr_count = 0
|
|
33
|
+
|
|
34
|
+
def flush():
|
|
35
|
+
nonlocal curr_char, curr_style, curr_count
|
|
36
|
+
if curr_count > 0:
|
|
37
|
+
t.append(curr_char * curr_count, style=curr_style)
|
|
38
|
+
curr_count = 0
|
|
39
|
+
|
|
40
|
+
for i in range(width):
|
|
41
|
+
cell_db = lo + (i + 0.5) / width * (hi - lo)
|
|
42
|
+
if i == hold_pos and hold_db > lo:
|
|
43
|
+
ch = "┃"
|
|
44
|
+
st = level_style(hold_db)
|
|
45
|
+
elif i < filled:
|
|
46
|
+
ch = "█"
|
|
47
|
+
st = level_style(cell_db)
|
|
48
|
+
else:
|
|
49
|
+
ch = "·"
|
|
50
|
+
st = "grey23"
|
|
51
|
+
|
|
52
|
+
if ch == curr_char and st == curr_style:
|
|
53
|
+
curr_count += 1
|
|
54
|
+
else:
|
|
55
|
+
flush()
|
|
56
|
+
curr_char = ch
|
|
57
|
+
curr_style = st
|
|
58
|
+
curr_count = 1
|
|
59
|
+
flush()
|
|
60
|
+
return t
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reduction_bar(db: float, width: int, max_db: float = 24.0, style: str = "cyan") -> Text:
|
|
64
|
+
filled = int(round(_clamp(db / max_db) * width))
|
|
65
|
+
t = Text()
|
|
66
|
+
t.append("█" * filled, style=style)
|
|
67
|
+
t.append("·" * (width - filled), style="grey23")
|
|
68
|
+
return t
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_bar(frac: float, width: int) -> Text:
|
|
72
|
+
frac = _clamp(frac)
|
|
73
|
+
style = "green" if frac < 0.5 else "yellow" if frac < 0.8 else "bold red"
|
|
74
|
+
filled = int(round(frac * width))
|
|
75
|
+
t = Text()
|
|
76
|
+
t.append("█" * filled, style=style)
|
|
77
|
+
t.append("·" * (width - filled), style="grey23")
|
|
78
|
+
return t
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def spectrum_rows(levels_db: np.ndarray, height: int, rep: int = 1, lo: float = -95.0, hi: float = -35.0,
|
|
82
|
+
style: str = "green") -> list[Text]:
|
|
83
|
+
"""Multi-row bar spectrum using eighth-block characters. Row 0 is the top."""
|
|
84
|
+
norm = np.clip((np.asarray(levels_db, dtype=float) - lo) / (hi - lo), 0.0, 1.0)
|
|
85
|
+
eighths = norm * height * 8.0
|
|
86
|
+
rows = []
|
|
87
|
+
for r in range(height - 1, -1, -1):
|
|
88
|
+
chars = []
|
|
89
|
+
for e in eighths:
|
|
90
|
+
cell = int(round(_clamp(e - r * 8.0, 0.0, 8.0)))
|
|
91
|
+
chars.append(BLOCKS[cell] * rep)
|
|
92
|
+
rows.append(Text("".join(chars), style=style))
|
|
93
|
+
return rows
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def freq_axis(n_bands: int, rep: int, f_lo: float, f_hi: float) -> Text:
|
|
97
|
+
width = n_bands * rep
|
|
98
|
+
chars = [" "] * width
|
|
99
|
+
for f, label in ((100, "100"), (1000, "1k"), (10000, "10k")):
|
|
100
|
+
if f_lo < f < f_hi:
|
|
101
|
+
pos = int(n_bands * math.log(f / f_lo) / math.log(f_hi / f_lo) * rep)
|
|
102
|
+
for j, c in enumerate(label):
|
|
103
|
+
if 0 <= pos + j < width:
|
|
104
|
+
chars[pos + j] = c
|
|
105
|
+
return Text("".join(chars), style="grey50")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fmt_db(db: float) -> str:
|
|
109
|
+
return " -inf" if db <= -119.0 else f"{db:6.1f}"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Windows Registry Run key helper for starting Micforge on boot."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger("micforge.autostart")
|
|
9
|
+
|
|
10
|
+
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
11
|
+
APP_NAME = "Micforge"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_autostart_enabled() -> bool:
|
|
15
|
+
"""Return True if Micforge is registered in HKCU Run key."""
|
|
16
|
+
if os.name != "nt":
|
|
17
|
+
return False
|
|
18
|
+
try:
|
|
19
|
+
import winreg
|
|
20
|
+
|
|
21
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_READ) as key:
|
|
22
|
+
val, _ = winreg.QueryValueEx(key, APP_NAME)
|
|
23
|
+
return bool(val)
|
|
24
|
+
except OSError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_autostart(enabled: bool) -> bool:
|
|
29
|
+
"""Enable or disable Micforge auto-start on Windows user login."""
|
|
30
|
+
if os.name != "nt":
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
import winreg
|
|
34
|
+
|
|
35
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
|
|
36
|
+
if enabled:
|
|
37
|
+
# Use pythonw if running via python interpreter to avoid opening a blank console
|
|
38
|
+
py_exe = sys.executable
|
|
39
|
+
if "python.exe" in py_exe.lower():
|
|
40
|
+
pyw = py_exe.lower().replace("python.exe", "pythonw.exe")
|
|
41
|
+
if os.path.exists(pyw):
|
|
42
|
+
py_exe = pyw
|
|
43
|
+
cmd = f'"{py_exe}" -m micforge.cli run --tray'
|
|
44
|
+
winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, cmd)
|
|
45
|
+
log.info("Registered Micforge autostart command: %s", cmd)
|
|
46
|
+
else:
|
|
47
|
+
try:
|
|
48
|
+
winreg.DeleteValue(key, APP_NAME)
|
|
49
|
+
log.info("Removed Micforge autostart command")
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
return True
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
log.warning("Could not update Windows registry autostart key: %s", exc)
|
|
55
|
+
return False
|
|
56
|
+
|