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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """micforge - real-time microphone clean-up for voice chat."""
2
+
3
+ __version__ = "1.3.0"
micforge/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,133 @@
1
+ """Offline speech-signal statistics and plain-language diagnosis."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from scipy.signal import welch
9
+
10
+ _EPS = 1e-12
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AudioStats:
15
+ duration_s: float
16
+ peak_db: float
17
+ rms_db: float
18
+ active_rms_db: float | None # level while talking
19
+ noise_floor_db: float
20
+ snr_db: float | None
21
+ clip_samples: int
22
+ presence_db: float | None # energy 2.5-8 kHz relative to 100-2.5 kHz (higher = brighter)
23
+ centroid_hz: float | None
24
+ active_pct: float
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Finding:
29
+ level: str # ok | warn | bad
30
+ text: str
31
+
32
+
33
+ def _db(p: float) -> float:
34
+ return 10.0 * math.log10(max(p, _EPS))
35
+
36
+
37
+ def analyze(x: np.ndarray, sr: int) -> AudioStats:
38
+ x = np.asarray(x, dtype=np.float64)
39
+ n = x.shape[0]
40
+ if n < sr // 5:
41
+ raise ValueError("recording too short to analyse")
42
+ frame = max(1, int(0.02 * sr))
43
+ m = n // frame
44
+ frames = x[: m * frame].reshape(m, frame)
45
+ fr_db = 10.0 * np.log10(np.maximum(np.mean(frames**2, axis=1), _EPS))
46
+ noise_floor = float(np.percentile(fr_db, 10))
47
+ loud = float(np.percentile(fr_db, 95))
48
+
49
+ has_speech = (loud - noise_floor) >= 12.0
50
+ active_rms = snr = presence = centroid = None
51
+ active_pct = 0.0
52
+ if has_speech:
53
+ thr = max(noise_floor + 10.0, loud - 30.0)
54
+ mask = fr_db > thr
55
+ active_pct = 100.0 * float(np.mean(mask))
56
+ if mask.any():
57
+ active = frames[mask]
58
+ active_rms = _db(float(np.mean(active**2)))
59
+ snr = active_rms - noise_floor
60
+ if active.size >= int(0.3 * sr):
61
+ f, p = welch(active.reshape(-1), sr, nperseg=min(1024, active.size))
62
+ hi_edge = min(8000.0, 0.45 * sr)
63
+ lo = (f >= 100) & (f < 2500)
64
+ hi = (f >= 2500) & (f < hi_edge)
65
+ if lo.any() and hi.any():
66
+ presence = _db(float(p[hi].sum())) - _db(float(p[lo].sum()))
67
+ band = (f >= 100) & (f <= hi_edge)
68
+ if band.any() and p[band].sum() > 0:
69
+ centroid = float(np.sum(f[band] * p[band]) / np.sum(p[band]))
70
+
71
+ return AudioStats(
72
+ duration_s=n / sr,
73
+ peak_db=_db(float(np.max(np.abs(x))) ** 2),
74
+ rms_db=_db(float(np.mean(x**2))),
75
+ active_rms_db=active_rms,
76
+ noise_floor_db=noise_floor,
77
+ snr_db=snr,
78
+ clip_samples=int(np.count_nonzero(np.abs(x) >= 0.999)),
79
+ presence_db=presence,
80
+ centroid_hz=centroid,
81
+ active_pct=active_pct,
82
+ )
83
+
84
+
85
+ def band_levels_db(x: np.ndarray, sr: int, n_bands: int = 32, f_lo: float = 60.0, f_hi: float = 16000.0) -> np.ndarray:
86
+ """Long-term average spectrum in log-spaced bands (dB, relative scale)."""
87
+ f_hi = min(f_hi, 0.45 * sr)
88
+ f, p = welch(np.asarray(x, dtype=np.float64), sr, nperseg=2048)
89
+ edges = np.geomspace(f_lo, f_hi, n_bands + 1)
90
+ out = np.full(n_bands, -120.0)
91
+ for i in range(n_bands):
92
+ sel = (f >= edges[i]) & (f < edges[i + 1])
93
+ if sel.any():
94
+ out[i] = _db(float(p[sel].mean()))
95
+ return out
96
+
97
+
98
+ def clarity_label(presence_db: float | None) -> str:
99
+ """Heuristic label; calibrated for typical close-mic speech, treat as a hint, not a measurement."""
100
+ if presence_db is None:
101
+ return "n/a"
102
+ if presence_db >= -22.0:
103
+ return "clear"
104
+ if presence_db >= -30.0:
105
+ return "a bit dull"
106
+ return "muffled"
107
+
108
+
109
+ def diagnose(s: AudioStats) -> list[Finding]:
110
+ out: list[Finding] = []
111
+ if s.active_rms_db is None:
112
+ out.append(Finding("warn", "Not enough speech detected - speak louder or closer to the mic for a reliable read."))
113
+ return out
114
+ if s.clip_samples > 0 or s.peak_db > -1.0:
115
+ out.append(Finding("bad", "Clipping detected. Lower the mic level in system settings (and turn off mic boost)."))
116
+ if s.active_rms_db < -35.0:
117
+ out.append(Finding("warn", "Very quiet voice. Raise the mic level in system settings; the leveler can only add ~9 dB."))
118
+ elif s.active_rms_db > -12.0:
119
+ out.append(Finding("warn", "Very hot signal. Lower the mic level a little to leave headroom."))
120
+ if s.noise_floor_db > -40.0:
121
+ out.append(Finding("bad", "Very high noise floor. Use the `noisy_room` preset, or move the mic closer / away from fans."))
122
+ elif s.noise_floor_db > -52.0:
123
+ out.append(Finding("warn", "Noticeable background noise. Run `micforge calibrate` in your usual room conditions."))
124
+ if s.snr_db is not None and s.snr_db < 20.0:
125
+ out.append(Finding("warn", f"Low signal-to-noise ratio ({s.snr_db:.0f} dB). Closer mic placement helps more than any filter."))
126
+ label = clarity_label(s.presence_db)
127
+ if label == "muffled":
128
+ out.append(Finding("bad", "Voice sounds muffled. Try the `de_muffle` preset, and check mic position (aim at your mouth, no cloth/foam blocking it)."))
129
+ elif label == "a bit dull":
130
+ out.append(Finding("warn", "Slightly dull top end. The `discord` or `de_muffle` preset should help."))
131
+ if not out:
132
+ out.append(Finding("ok", "Signal looks healthy: good level, low noise, clear top end."))
133
+ return out
File without changes
@@ -0,0 +1,32 @@
1
+ """Lazy access to sounddevice so `--help`, tests and offline commands work without PortAudio."""
2
+ from __future__ import annotations
3
+
4
+ from types import ModuleType
5
+
6
+ from ..errors import AudioBackendError
7
+
8
+ _sd: ModuleType | None = None
9
+
10
+
11
+ def get_sd() -> ModuleType:
12
+ global _sd
13
+ if _sd is None:
14
+ try:
15
+ import sounddevice as sd # noqa: PLC0415
16
+ except ImportError as exc:
17
+ raise AudioBackendError(
18
+ "The 'sounddevice' package is not installed.", hint="pip install sounddevice"
19
+ ) from exc
20
+ except OSError as exc:
21
+ raise AudioBackendError(
22
+ f"PortAudio library not found ({exc}).",
23
+ hint="Linux: sudo apt install libportaudio2 | macOS: brew install portaudio",
24
+ ) from exc
25
+ _sd = sd
26
+ return _sd
27
+
28
+
29
+ def set_backend(module: ModuleType | None) -> None:
30
+ """Inject a backend (used by tests)."""
31
+ global _sd
32
+ _sd = module
micforge/audio/cues.py ADDED
@@ -0,0 +1,110 @@
1
+ """Procedural audio feedback chimes (blip for mute, chirp for unmute).
2
+
3
+ Synthesized dynamically in NumPy with zero external sound files and zero disk I/O.
4
+ Mixed directly into the headphone/monitor sink so Discord/callers never hear them.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import threading
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ log = logging.getLogger("micforge.cues")
15
+
16
+
17
+ class CuePlayer:
18
+ """Thread-safe procedural audio cue synthesizer and mixer."""
19
+
20
+ def __init__(self, sample_rate: int = 48000, volume: float = 0.5, enabled: bool = True) -> None:
21
+ self.sr = sample_rate
22
+ self.volume = max(0.0, min(1.0, float(volume)))
23
+ self.enabled = enabled
24
+ self._buf = np.zeros(0, dtype=np.float32)
25
+ self._lock = threading.Lock()
26
+ self._cache: dict[str, np.ndarray] = {}
27
+ self._pregenerate()
28
+
29
+ def set_volume(self, vol: float) -> None:
30
+ self.volume = max(0.0, min(1.0, float(vol)))
31
+
32
+ def set_enabled(self, on: bool) -> None:
33
+ self.enabled = bool(on)
34
+
35
+ def _pregenerate(self) -> None:
36
+ """Pre-render chime waveforms into cache to avoid any runtime allocation."""
37
+ sr = self.sr
38
+ # 1. Blip (Mute): gentle descending chirp 460 Hz -> 280 Hz over 70ms
39
+ dur = 0.075
40
+ n = int(sr * dur)
41
+ t = np.linspace(0, dur, n, endpoint=False)
42
+ freq = np.linspace(460, 280, n)
43
+ phase = 2 * np.pi * np.cumsum(freq) / sr
44
+ env = np.sin(np.pi * np.linspace(0, 1, n)) ** 2
45
+ blip = (0.28 * np.sin(phase) * env).astype(np.float32)
46
+
47
+ # 2. Chirp (Unmute): gentle ascending chirp 280 Hz -> 540 Hz over 70ms
48
+ freq_up = np.linspace(280, 540, n)
49
+ phase_up = 2 * np.pi * np.cumsum(freq_up) / sr
50
+ chirp = (0.28 * np.sin(phase_up) * env).astype(np.float32)
51
+
52
+ # 3. Toggle (Bypass): subtle double-pip
53
+ pip_dur = 0.02
54
+ pip_n = int(sr * pip_dur)
55
+ pip_t = np.linspace(0, pip_dur, pip_n, endpoint=False)
56
+ pip_env = np.sin(np.pi * np.linspace(0, 1, pip_n)) ** 2
57
+ pip = 0.22 * np.sin(2 * np.pi * 580 * pip_t) * pip_env
58
+ gap = np.zeros(int(sr * 0.025), dtype=np.float32)
59
+ toggle = np.concatenate([pip, gap, pip]).astype(np.float32)
60
+
61
+ self._cache = {
62
+ "mute": blip,
63
+ "unmute": chirp,
64
+ "toggle": toggle,
65
+ }
66
+
67
+ def play(self, kind: str) -> None:
68
+ """Queue a chime to be mixed into the next outgoing headphone blocks."""
69
+ if not self.enabled or self.volume <= 0.0:
70
+ return
71
+ wave = self._cache.get(kind)
72
+ if wave is None:
73
+ return
74
+ scaled = wave * np.float32(self.volume)
75
+ with self._lock:
76
+ # Overwrite or append seamlessly
77
+ self._buf = scaled.copy()
78
+
79
+ def mix_into(self, out: np.ndarray) -> np.ndarray:
80
+ """Mix queued chime samples directly into headphone/monitor audio block."""
81
+ if not self.enabled or self._buf.size == 0:
82
+ return out
83
+ with self._lock:
84
+ if self._buf.size == 0:
85
+ return out
86
+ n = min(out.shape[0], self._buf.shape[0])
87
+ res = out.copy()
88
+ res[:n] += self._buf[:n]
89
+ self._buf = self._buf[n:]
90
+ return res
91
+
92
+ def play_standalone(self, kind: str, device: Any = None) -> None:
93
+ """Play a cue directly through sounddevice if headphone monitoring is not active."""
94
+ if not self.enabled or self.volume <= 0.0:
95
+ return
96
+ wave = self._cache.get(kind)
97
+ if wave is None:
98
+ return
99
+ scaled = wave * np.float32(self.volume)
100
+
101
+ def _worker():
102
+ try:
103
+ import sounddevice as sd
104
+
105
+ sd.play(scaled, samplerate=self.sr, device=device, blocking=False)
106
+ except Exception as exc: # noqa: BLE001
107
+ log.debug("Standalone cue playback failed: %s", exc)
108
+
109
+ threading.Thread(target=_worker, daemon=True).start()
110
+
@@ -0,0 +1,237 @@
1
+ """Hot-plug detection and auto-recovery for audio devices."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ import logging
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Any, Callable
10
+
11
+ from .devices import DeviceInfo, list_devices
12
+
13
+ log = logging.getLogger("micforge.device_watch")
14
+
15
+
16
+ class DeviceEventType(str, enum.Enum):
17
+ DEVICE_ADDED = "device_added"
18
+ DEVICE_REMOVED = "device_removed"
19
+ DEFAULT_CHANGED = "default_changed"
20
+ STREAM_ERROR = "stream_error"
21
+ RECOVERY_STARTED = "recovery_started"
22
+ RECOVERY_SUCCEEDED = "recovery_succeeded"
23
+ RECOVERY_FAILED = "recovery_failed"
24
+
25
+
26
+ @dataclass
27
+ class DeviceWatchEvent:
28
+ event_type: DeviceEventType
29
+ message: str
30
+ device_name: str | None = None
31
+ device_index: int | None = None
32
+ timestamp: float = 0.0
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.timestamp == 0.0:
36
+ self.timestamp = time.time()
37
+
38
+
39
+ class DeviceWatcher:
40
+ """Monitors audio device topology changes and initiates auto-recovery on disconnects."""
41
+
42
+ def __init__(
43
+ self,
44
+ poll_interval_s: float = 1.5,
45
+ max_retries: int = 5,
46
+ backoff_base_s: float = 0.5,
47
+ on_recovery_attempt: Callable[[str, int | str | None], bool] | None = None,
48
+ on_fallback: Callable[[], None] | None = None,
49
+ ) -> None:
50
+ self.poll_interval = poll_interval_s
51
+ self.max_retries = max_retries
52
+ self.backoff_base = backoff_base_s
53
+ self.on_recovery_attempt = on_recovery_attempt
54
+ self.on_fallback = on_fallback
55
+
56
+ self._listeners: list[Callable[[DeviceWatchEvent], None]] = []
57
+ self._thread: threading.Thread | None = None
58
+ self._running = False
59
+ self._lock = threading.Lock()
60
+
61
+ # Cached device state
62
+ self._cached_inputs: set[str] = set()
63
+ self._cached_outputs: set[str] = set()
64
+ self._cached_default_in: int | None = None
65
+ self._cached_default_out: int | None = None
66
+ self._recovering = False
67
+
68
+ self._refresh_cached_state()
69
+
70
+ @property
71
+ def is_recovering(self) -> bool:
72
+ with self._lock:
73
+ return self._recovering
74
+
75
+ def add_listener(self, cb: Callable[[DeviceWatchEvent], None]) -> None:
76
+ with self._lock:
77
+ if cb not in self._listeners:
78
+ self._listeners.append(cb)
79
+
80
+ def remove_listener(self, cb: Callable[[DeviceWatchEvent], None]) -> None:
81
+ with self._lock:
82
+ if cb in self._listeners:
83
+ self._listeners.remove(cb)
84
+
85
+ def _emit(self, event_type: DeviceEventType, message: str, dev_name: str | None = None, dev_idx: int | None = None) -> DeviceWatchEvent:
86
+ evt = DeviceWatchEvent(
87
+ event_type=event_type,
88
+ message=message,
89
+ device_name=dev_name,
90
+ device_index=dev_idx,
91
+ )
92
+ log.info("DeviceWatch event [%s]: %s (dev=%s)", event_type.value, message, dev_name or "none")
93
+ with self._lock:
94
+ listeners = list(self._listeners)
95
+ for cb in listeners:
96
+ try:
97
+ cb(evt)
98
+ except Exception as exc:
99
+ log.debug("Listener callback exception: %s", exc)
100
+ return evt
101
+
102
+ def _refresh_cached_state(self) -> list[DeviceInfo]:
103
+ try:
104
+ devs = list_devices()
105
+ self._cached_inputs = {d.name for d in devs if d.max_input > 0}
106
+ self._cached_outputs = {d.name for d in devs if d.max_output > 0}
107
+ def_in = next((d.index for d in devs if d.is_default_input), None)
108
+ def_out = next((d.index for d in devs if d.is_default_output), None)
109
+ self._cached_default_in = def_in
110
+ self._cached_default_out = def_out
111
+ return devs
112
+ except Exception as exc:
113
+ log.debug("Device query failure in watcher: %s", exc)
114
+ return []
115
+
116
+ def start(self) -> None:
117
+ if self._running:
118
+ return
119
+ self._running = True
120
+ self._thread = threading.Thread(target=self._run_loop, name="micforge-dev-watch", daemon=True)
121
+ self._thread.start()
122
+
123
+ def stop(self) -> None:
124
+ self._running = False
125
+ if self._thread and self._thread.is_alive():
126
+ self._thread.join(timeout=1.0)
127
+ self._thread = None
128
+
129
+ def trigger_stream_error(self, stream_kind: str, device_name: str | None = None) -> None:
130
+ """Called by audio engine if a stream callback or write raises a device error."""
131
+ self._emit(
132
+ DeviceEventType.STREAM_ERROR,
133
+ f"Audio stream error on {stream_kind} stream ({device_name or 'unknown device'})",
134
+ dev_name=device_name,
135
+ )
136
+ self.schedule_recovery(stream_kind, device_name)
137
+
138
+ def schedule_recovery(self, kind: str, target_device: int | str | None = None) -> None:
139
+ """Asynchronously initiate bounded retry recovery with backoff."""
140
+ if self._recovering:
141
+ return
142
+ threading.Thread(target=self._perform_recovery, args=(kind, target_device), daemon=True).start()
143
+
144
+ def _perform_recovery(self, kind: str, target_device: int | str | None) -> bool:
145
+ with self._lock:
146
+ if self._recovering:
147
+ return False
148
+ self._recovering = True
149
+
150
+ self._emit(DeviceEventType.RECOVERY_STARTED, f"Starting auto-recovery for {kind}...", dev_name=str(target_device))
151
+
152
+ recovered = False
153
+ for attempt in range(1, self.max_retries + 1):
154
+ delay = self.backoff_base * (1.6 ** (attempt - 1))
155
+ log.info("Recovery attempt %d/%d in %.2fs...", attempt, self.max_retries, delay)
156
+ time.sleep(delay)
157
+
158
+ try:
159
+ # Refresh hardware list
160
+ self._refresh_cached_state()
161
+ if self.on_recovery_attempt is not None:
162
+ success = self.on_recovery_attempt(kind, target_device)
163
+ if success:
164
+ self._emit(
165
+ DeviceEventType.RECOVERY_SUCCEEDED,
166
+ f"Successfully recovered {kind} stream on attempt {attempt}!",
167
+ dev_name=str(target_device),
168
+ )
169
+ recovered = True
170
+ break
171
+ except Exception as exc:
172
+ log.warning("Recovery attempt %d failed: %s", attempt, exc)
173
+
174
+ if not recovered:
175
+ self._emit(
176
+ DeviceEventType.RECOVERY_FAILED,
177
+ f"Auto-recovery failed after {self.max_retries} attempts. Engaging safe passthrough fallback.",
178
+ dev_name=str(target_device),
179
+ )
180
+ if self.on_fallback is not None:
181
+ try:
182
+ self.on_fallback()
183
+ except Exception as exc:
184
+ log.error("Fallback handler error: %s", exc)
185
+
186
+ with self._lock:
187
+ self._recovering = False
188
+ return recovered
189
+
190
+ def check_devices(self, devs: list[DeviceInfo] | None = None) -> list[DeviceWatchEvent]:
191
+ """Check given (or current) device list against cached state, emitting events on changes."""
192
+ if devs is None:
193
+ devs = list_devices()
194
+ curr_inputs = {d.name for d in devs if d.max_input > 0}
195
+ curr_outputs = {d.name for d in devs if d.max_output > 0}
196
+ curr_def_in = next((d.index for d in devs if d.is_default_input), None)
197
+ curr_def_out = next((d.index for d in devs if d.is_default_output), None)
198
+
199
+ events: list[DeviceWatchEvent] = []
200
+ removed_in = self._cached_inputs - curr_inputs
201
+ for name in removed_in:
202
+ events.append(self._emit(DeviceEventType.DEVICE_REMOVED, f"Input device disconnected: {name}", dev_name=name))
203
+
204
+ removed_out = self._cached_outputs - curr_outputs
205
+ for name in removed_out:
206
+ events.append(self._emit(DeviceEventType.DEVICE_REMOVED, f"Output device disconnected: {name}", dev_name=name))
207
+
208
+ added_in = curr_inputs - self._cached_inputs
209
+ for name in added_in:
210
+ events.append(self._emit(DeviceEventType.DEVICE_ADDED, f"New input device detected: {name}", dev_name=name))
211
+
212
+ added_out = curr_outputs - self._cached_outputs
213
+ for name in added_out:
214
+ events.append(self._emit(DeviceEventType.DEVICE_ADDED, f"New output device detected: {name}", dev_name=name))
215
+
216
+ if curr_def_in != self._cached_default_in or curr_def_out != self._cached_default_out:
217
+ events.append(self._emit(
218
+ DeviceEventType.DEFAULT_CHANGED,
219
+ f"Default audio endpoint changed (in: {curr_def_in}, out: {curr_def_out})",
220
+ ))
221
+
222
+ self._cached_inputs = curr_inputs
223
+ self._cached_outputs = curr_outputs
224
+ self._cached_default_in = curr_def_in
225
+ self._cached_default_out = curr_def_out
226
+ return events
227
+
228
+ def _run_loop(self) -> None:
229
+ while self._running:
230
+ time.sleep(self.poll_interval)
231
+ if not self._running or self._recovering:
232
+ continue
233
+
234
+ try:
235
+ self.check_devices()
236
+ except Exception as exc:
237
+ log.debug("Device watcher check error: %s", exc)
@@ -0,0 +1,160 @@
1
+ """Device discovery and selection, with host-API de-duplication and virtual-cable detection."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..errors import DeviceError
9
+ from .backend import get_sd
10
+
11
+ HOSTAPI_PRIORITY = [
12
+ "Windows WASAPI",
13
+ "Core Audio",
14
+ "JACK Audio Connection Kit",
15
+ "ALSA",
16
+ "Windows DirectSound",
17
+ "MME",
18
+ "OSS",
19
+ "Windows WDM-KS",
20
+ ]
21
+
22
+ # Playback-side names of virtual cables, best first. Discord then picks the matching *recording* end.
23
+ VIRTUAL_OUTPUT_PATTERNS = [
24
+ r"cable input",
25
+ r"vb-audio",
26
+ r"voicemeeter (aux )?input",
27
+ r"blackhole",
28
+ r"soundflower",
29
+ r"loopback",
30
+ r"virtual",
31
+ r"null",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class DeviceInfo:
37
+ index: int
38
+ name: str
39
+ hostapi: str
40
+ max_input: int
41
+ max_output: int
42
+ default_rate: float
43
+ is_default_input: bool
44
+ is_default_output: bool
45
+
46
+ @property
47
+ def is_virtual(self) -> bool:
48
+ low = self.name.lower()
49
+ return any(re.search(p, low) for p in VIRTUAL_OUTPUT_PATTERNS)
50
+
51
+ @property
52
+ def is_wasapi(self) -> bool:
53
+ return "WASAPI" in self.hostapi
54
+
55
+
56
+ def _priority(api: str) -> int:
57
+ return HOSTAPI_PRIORITY.index(api) if api in HOSTAPI_PRIORITY else len(HOSTAPI_PRIORITY)
58
+
59
+
60
+ def list_devices(all_apis: bool = False) -> list[DeviceInfo]:
61
+ """All devices; by default only those on the best host API available (hides MME/DirectSound dupes)."""
62
+ sd = get_sd()
63
+ apis = sd.query_hostapis()
64
+
65
+ # Identify default devices from the highest-priority Host API available.
66
+ # On Windows, this ensures WASAPI default devices are selected rather than legacy MME defaults.
67
+ sorted_apis = sorted(apis, key=lambda a: _priority(str(a.get("name", ""))))
68
+ best_in = next(
69
+ (a["default_input_device"] for a in sorted_apis if a.get("default_input_device", -1) >= 0),
70
+ -1,
71
+ )
72
+ best_out = next(
73
+ (a["default_output_device"] for a in sorted_apis if a.get("default_output_device", -1) >= 0),
74
+ -1,
75
+ )
76
+
77
+ default_in, default_out = sd.default.device
78
+ effective_in = best_in if best_in >= 0 else default_in
79
+ effective_out = best_out if best_out >= 0 else default_out
80
+
81
+ infos = []
82
+ for i, d in enumerate(sd.query_devices()):
83
+ infos.append(
84
+ DeviceInfo(
85
+ index=i,
86
+ name=str(d["name"]),
87
+ hostapi=str(apis[d["hostapi"]]["name"]),
88
+ max_input=int(d["max_input_channels"]),
89
+ max_output=int(d["max_output_channels"]),
90
+ default_rate=float(d["default_samplerate"]),
91
+ is_default_input=(i == effective_in),
92
+ is_default_output=(i == effective_out),
93
+ )
94
+ )
95
+ if all_apis or not infos:
96
+ return infos
97
+ best = min((_priority(d.hostapi) for d in infos), default=0)
98
+ # keep the best API, but never hide the system default device
99
+ return [d for d in infos if _priority(d.hostapi) == best or d.is_default_input or d.is_default_output]
100
+
101
+
102
+ def _usable(d: DeviceInfo, kind: str) -> bool:
103
+ return (d.max_input if kind == "input" else d.max_output) > 0
104
+
105
+
106
+ def find_device(spec: Any, kind: str, devices: list[DeviceInfo] | None = None) -> DeviceInfo:
107
+ """Resolve an index / name substring / None (=default) to a device."""
108
+ if kind not in ("input", "output"):
109
+ raise ValueError(kind)
110
+ pool = devices if devices is not None else list_devices()
111
+ candidates = [d for d in pool if _usable(d, kind)]
112
+ if not candidates:
113
+ raise DeviceError(f"No {kind} devices found.", hint="Plug one in, or run `micforge doctor`.")
114
+
115
+ if spec in (None, ""):
116
+ defaults = [d for d in candidates if (d.is_default_input if kind == "input" else d.is_default_output)]
117
+ if defaults:
118
+ defaults.sort(key=lambda d: (_priority(d.hostapi), d.index))
119
+ return defaults[0]
120
+ candidates.sort(key=lambda d: (_priority(d.hostapi), d.index))
121
+ return candidates[0]
122
+
123
+ if isinstance(spec, int) or (isinstance(spec, str) and spec.strip().isdigit()):
124
+ idx = int(spec)
125
+ for d in get_all(devices):
126
+ if d.index == idx and _usable(d, kind):
127
+ return d
128
+ raise DeviceError(f"Device index {idx} is not a valid {kind} device.", hint="See `micforge devices`.")
129
+
130
+ needle = str(spec).lower()
131
+ matches = [d for d in get_all(devices) if needle in d.name.lower() and _usable(d, kind)]
132
+ if not matches:
133
+ raise DeviceError(f"No {kind} device matches {spec!r}.", hint="See `micforge devices`.")
134
+ matches.sort(key=lambda d: (_priority(d.hostapi), d.index))
135
+ return matches[0]
136
+
137
+
138
+ def get_all(devices: list[DeviceInfo] | None) -> list[DeviceInfo]:
139
+ return devices if devices is not None else list_devices(all_apis=True)
140
+
141
+
142
+ def detect_virtual_output(devices: list[DeviceInfo] | None = None) -> DeviceInfo | None:
143
+ pool = [d for d in get_all(devices) if d.max_output > 0]
144
+ for pat in VIRTUAL_OUTPUT_PATTERNS:
145
+ hits = [d for d in pool if re.search(pat, d.name.lower())]
146
+ if hits:
147
+ hits.sort(key=lambda d: (_priority(d.hostapi), d.index))
148
+ return hits[0]
149
+ return None
150
+
151
+
152
+ def extra_settings(device: DeviceInfo, kind: str):
153
+ """WASAPI shared-mode auto-conversion lets us run at 48 kHz regardless of the mixer format."""
154
+ sd = get_sd()
155
+ if device.is_wasapi and hasattr(sd, "WasapiSettings"):
156
+ try:
157
+ return sd.WasapiSettings(auto_convert=True)
158
+ except TypeError:
159
+ return None
160
+ return None