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/__init__.py
ADDED
micforge/__main__.py
ADDED
|
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
|