micforge 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. micforge/__init__.py +3 -0
  2. micforge/__main__.py +4 -0
  3. micforge/analysis/__init__.py +0 -0
  4. micforge/analysis/stats.py +133 -0
  5. micforge/audio/__init__.py +0 -0
  6. micforge/audio/backend.py +32 -0
  7. micforge/audio/cues.py +110 -0
  8. micforge/audio/device_watch.py +237 -0
  9. micforge/audio/devices.py +160 -0
  10. micforge/audio/engine.py +746 -0
  11. micforge/audio/fileio.py +45 -0
  12. micforge/audio/latency.py +169 -0
  13. micforge/audio/loopback.py +113 -0
  14. micforge/audio/metrics.py +134 -0
  15. micforge/audio/noise_profile.py +250 -0
  16. micforge/audio/recorder.py +100 -0
  17. micforge/audio/ring.py +89 -0
  18. micforge/cli.py +293 -0
  19. micforge/commands/__init__.py +0 -0
  20. micforge/commands/calibrate.py +47 -0
  21. micforge/commands/common.py +69 -0
  22. micforge/commands/devices_cmd.py +86 -0
  23. micforge/commands/diagnostics_cmd.py +68 -0
  24. micforge/commands/doctor.py +127 -0
  25. micforge/commands/files.py +48 -0
  26. micforge/commands/help_cmd.py +449 -0
  27. micforge/commands/latency_test.py +65 -0
  28. micforge/commands/live.py +321 -0
  29. micforge/commands/preset_cmd.py +142 -0
  30. micforge/commands/test_mic.py +98 -0
  31. micforge/commands/wizard.py +70 -0
  32. micforge/config.py +279 -0
  33. micforge/diagnostics.py +308 -0
  34. micforge/dsp/__init__.py +0 -0
  35. micforge/dsp/aec.py +388 -0
  36. micforge/dsp/base.py +30 -0
  37. micforge/dsp/biquad.py +144 -0
  38. micforge/dsp/chain.py +153 -0
  39. micforge/dsp/cofilite.py +427 -0
  40. micforge/dsp/compressor.py +109 -0
  41. micforge/dsp/deesser.py +64 -0
  42. micforge/dsp/denoise.py +181 -0
  43. micforge/dsp/deverb.py +227 -0
  44. micforge/dsp/export_cofilite.py +111 -0
  45. micforge/dsp/gate.py +76 -0
  46. micforge/dsp/leveler.py +74 -0
  47. micforge/dsp/proximity.py +164 -0
  48. micforge/dsp/util.py +80 -0
  49. micforge/errors.py +25 -0
  50. micforge/logging_setup.py +25 -0
  51. micforge/models/cofilite.onnx +0 -0
  52. micforge/presets.py +284 -0
  53. micforge/profile_store.py +41 -0
  54. micforge/ui/__init__.py +0 -0
  55. micforge/ui/dashboard.py +573 -0
  56. micforge/ui/hotkeys.py +169 -0
  57. micforge/ui/keys.py +64 -0
  58. micforge/ui/report.py +88 -0
  59. micforge/ui/settings_gui.py +899 -0
  60. micforge/ui/theme.py +42 -0
  61. micforge/ui/tray.py +276 -0
  62. micforge/ui/tuning.py +138 -0
  63. micforge/ui/widgets.py +109 -0
  64. micforge/util/autostart.py +56 -0
  65. micforge-1.3.0.dist-info/METADATA +334 -0
  66. micforge-1.3.0.dist-info/RECORD +70 -0
  67. micforge-1.3.0.dist-info/WHEEL +5 -0
  68. micforge-1.3.0.dist-info/entry_points.txt +2 -0
  69. micforge-1.3.0.dist-info/licenses/LICENSE +22 -0
  70. micforge-1.3.0.dist-info/top_level.txt +1 -0
micforge/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
+