Skip to content

Commit b3ed570

Browse files
committed
fix: fix mic settings, fix calibration
1 parent c1c5f7e commit b3ed570

5 files changed

Lines changed: 103 additions & 17 deletions

File tree

src/audio.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
except OSError:
1919
sd = None # type: ignore[assignment]
2020

21+
from src.config import DEFAULT_RMS_THRESHOLD
2122
from src.utils import AppError, ScreamerError
2223

2324
log = logging.getLogger(__name__)
@@ -54,6 +55,16 @@ def list_devices() -> list[AudioDevice]:
5455
return result
5556

5657

58+
def default_input_device_id() -> int | None:
59+
"""Return PortAudio's default input device ID, if one is configured."""
60+
_require_sd()
61+
default = sd.default.device
62+
device_id = default[0] if isinstance(default, (list, tuple)) else default
63+
if device_id is None or int(device_id) < 0:
64+
return None
65+
return int(device_id)
66+
67+
5768
class AudioRecorder:
5869
def __init__(self, device_id: int | None = None, sample_rate: int = SAMPLE_RATE) -> None:
5970
self._device_id = device_id
@@ -62,7 +73,7 @@ def __init__(self, device_id: int | None = None, sample_rate: int = SAMPLE_RATE)
6273
self._stream: Any = None
6374
self._lock = threading.Lock()
6475
self._start_time: float = 0.0
65-
self._rms_threshold: float = 50.0
76+
self._rms_threshold: float = DEFAULT_RMS_THRESHOLD
6677

6778
@property
6879
def rms_threshold(self) -> float:
@@ -78,7 +89,7 @@ def is_recording(self) -> bool:
7889
return self._stream is not None
7990

8091
def calibrate(self, duration: float = 2.0) -> float:
81-
"""Record ambient noise, return noise_floor * 2.0. Fallback: 50."""
92+
"""Record ambient noise and return a usable silence-gate threshold."""
8293
_require_sd()
8394
try:
8495
log.info("Calibrating RMS threshold for %.1fs...", duration)
@@ -92,15 +103,15 @@ def calibrate(self, duration: float = 2.0) -> float:
92103
sd.wait()
93104
noise_floor = float(np.sqrt(np.mean(recording.astype(np.float64) ** 2)))
94105
threshold = noise_floor * 2.0
95-
if threshold < 1.0:
96-
threshold = 50.0
106+
if threshold < DEFAULT_RMS_THRESHOLD:
107+
threshold = DEFAULT_RMS_THRESHOLD
97108
self._rms_threshold = threshold
98109
log.info("Calibration done: noise_floor=%.1f, threshold=%.1f", noise_floor, threshold)
99110
return threshold
100111
except Exception as e:
101-
log.warning("Calibration failed: %s; using fallback 50", e)
102-
self._rms_threshold = 50.0
103-
return 50.0
112+
log.warning("Calibration failed: %s; using fallback %.1f", e, DEFAULT_RMS_THRESHOLD)
113+
self._rms_threshold = DEFAULT_RMS_THRESHOLD
114+
return DEFAULT_RMS_THRESHOLD
104115

105116
def _callback(self, indata: np.ndarray, frames: int, time_info, status) -> None: # type: ignore[no-untyped-def]
106117
if status:

src/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"Return only the corrected text with no explanations."
2020
)
2121

22+
DEFAULT_RMS_THRESHOLD = 5.0
23+
2224
MOD_CONTROL = 0x0002
2325
MOD_ALT = 0x0001
2426
MOD_SHIFT = 0x0004
@@ -100,7 +102,7 @@ class AppConfig:
100102
post_type_key: str = "none" # "none" | "enter" | "tab" | "space" | "backspace"
101103
audio_device_id: int | None = None
102104
audio_device_name: str = ""
103-
rms_threshold: float = 50.0
105+
rms_threshold: float = DEFAULT_RMS_THRESHOLD
104106
# STT primary
105107
stt_api_key: str = ""
106108
stt_base_url: str = ""

src/main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from PySide6.QtGui import QIcon
1616
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
1717

18-
from src.audio import AudioRecorder, resolve_device, list_devices
18+
from src.audio import AudioRecorder, default_input_device_id, list_devices, resolve_device
1919
from src.config import (
2020
HOTKEY_OPTIONS,
2121
POST_KEY_OPTIONS,
@@ -132,7 +132,14 @@ def __init__(self) -> None:
132132
def _get_device_list(self) -> list[tuple[int, str]]:
133133
"""Return list of (device_id, name) for the settings dialog."""
134134
try:
135-
return [(d.id, d.name) for d in list_devices()]
135+
default_id = default_input_device_id()
136+
devices = []
137+
for d in list_devices():
138+
name = d.name
139+
if d.id == default_id:
140+
name = f"{name} (Default input)"
141+
devices.append((d.id, name))
142+
return devices
136143
except Exception as e:
137144
log.warning("Could not enumerate audio devices: %s", e)
138145
return []

src/settings_dialog.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
QPlainTextEdit,
2626
QPushButton,
2727
QRadioButton,
28+
QDoubleSpinBox,
2829
QTabWidget,
2930
QVBoxLayout,
3031
QWidget,
@@ -258,6 +259,14 @@ def _build_audio_tab(self) -> None:
258259
tab = QWidget()
259260
form = QFormLayout(tab)
260261

262+
hint = QLabel(
263+
"No external mic needed: use System Default for the built-in laptop mic, "
264+
"or pick the device named Microphone Array, Internal Mic, Realtek, DMIC, "
265+
"or Intel Smart Sound. Avoid Monitor, Stereo Mix, HDMI, and output devices."
266+
)
267+
hint.setWordWrap(True)
268+
form.addRow(hint)
269+
261270
self._device_combo = QComboBox()
262271
self._populate_devices()
263272
form.addRow("Input Device:", self._device_combo)
@@ -268,6 +277,13 @@ def _build_audio_tab(self) -> None:
268277
self._calibrate_btn.setEnabled(False)
269278
form.addRow(self._calibrate_btn)
270279

280+
self._rms_spin = QDoubleSpinBox()
281+
self._rms_spin.setRange(0.0, 32767.0)
282+
self._rms_spin.setDecimals(1)
283+
self._rms_spin.setSingleStep(1.0)
284+
self._rms_spin.setSpecialValueText("Disabled")
285+
form.addRow("RMS Threshold:", self._rms_spin)
286+
271287
self._rms_label = QLabel("Threshold: —")
272288
form.addRow(self._rms_label)
273289

@@ -313,6 +329,7 @@ def _populate(self, cfg: AppConfig) -> None:
313329
self._llm_fb_headers.setText(cfg.llm_fallback_custom_headers)
314330

315331
# Audio
332+
self._rms_spin.setValue(cfg.rms_threshold)
316333
self._rms_label.setText(f"Threshold: {cfg.rms_threshold:.1f}")
317334

318335
def _collect(self) -> None:
@@ -351,6 +368,7 @@ def _collect(self) -> None:
351368

352369
# Audio
353370
cfg.audio_device_id = self._device_combo.currentData()
371+
cfg.rms_threshold = self._rms_spin.value()
354372
cfg.audio_device_name = ""
355373
if cfg.audio_device_id is not None:
356374
text = self._device_combo.currentText()
@@ -362,7 +380,7 @@ def _collect(self) -> None:
362380

363381
def _populate_devices(self) -> None:
364382
"""Fill the device combo from the pre-fetched device list."""
365-
self._device_combo.addItem("(System Default)", None)
383+
self._device_combo.addItem("System Default (usually built-in laptop mic)", None)
366384
for dev_id, dev_name in self._devices:
367385
self._device_combo.addItem(f"[{dev_id}] {dev_name}", dev_id)
368386

@@ -387,6 +405,7 @@ def _on_calibrate(self) -> None:
387405
)
388406
threshold = self._calibrate_fn(device_id)
389407
self._working.rms_threshold = threshold
408+
self._rms_spin.setValue(threshold)
390409
self._rms_label.setText(f"Threshold: {threshold:.1f}")
391410
except Exception as e:
392411
QMessageBox.warning(self, "Calibration Failed", str(e))
@@ -398,13 +417,9 @@ def _on_calibrate(self) -> None:
398417
def _set_dynamic_group_visible(self, group: QWidget, visible: bool) -> None:
399418
group.setVisible(visible)
400419

401-
parent = group.parentWidget()
402-
if parent is not None and parent.layout() is not None:
403-
parent.layout().invalidate()
404-
405420
if self.layout() is not None:
406-
self.layout().invalidate()
407-
self.adjustSize()
421+
self.layout().activate()
422+
self.resize(self.sizeHint())
408423

409424
# ------------------------------------------------------------------
410425
# Validation

tests/test_audio.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
import numpy as np
5+
6+
from src.audio import AudioRecorder
7+
from src.config import DEFAULT_RMS_THRESHOLD
8+
9+
10+
class FakeSoundDevice:
11+
def __init__(self, recording: np.ndarray | None = None, error: Exception | None = None) -> None:
12+
self.recording = recording
13+
self.error = error
14+
15+
def rec(self, *args, **kwargs):
16+
if self.error is not None:
17+
raise self.error
18+
return self.recording
19+
20+
def wait(self) -> None:
21+
return None
22+
23+
24+
class AudioCalibrationTests(unittest.TestCase):
25+
def test_calibration_uses_low_default_for_silent_input(self) -> None:
26+
fake_sd = FakeSoundDevice(np.zeros((16000, 1), dtype=np.int16))
27+
28+
with patch("src.audio.sd", fake_sd):
29+
threshold = AudioRecorder().calibrate(1.0)
30+
31+
self.assertEqual(threshold, DEFAULT_RMS_THRESHOLD)
32+
33+
def test_calibration_scales_audible_noise_floor(self) -> None:
34+
fake_sd = FakeSoundDevice(np.full((16000, 1), 10, dtype=np.int16))
35+
36+
with patch("src.audio.sd", fake_sd):
37+
threshold = AudioRecorder().calibrate(1.0)
38+
39+
self.assertEqual(threshold, 20.0)
40+
41+
def test_calibration_failure_uses_low_default(self) -> None:
42+
fake_sd = FakeSoundDevice(error=RuntimeError("no mic"))
43+
44+
with patch("src.audio.sd", fake_sd):
45+
threshold = AudioRecorder().calibrate(1.0)
46+
47+
self.assertEqual(threshold, DEFAULT_RMS_THRESHOLD)
48+
49+
50+
if __name__ == "__main__":
51+
unittest.main()

0 commit comments

Comments
 (0)