fix(voice): capture at the input device's native sample rate

AudioRecorder hard-codes SAMPLE_RATE (16 kHz) when opening the input
stream, but some capture devices (e.g. USB microphones exposed through
ALSA hw) reject 16 kHz outright — sd.InputStream fails with
PaErrorCode -9997 (Invalid sample rate) and voice recording is broken.

Query the default input device for its native default_samplerate at
recording start and open the stream / write the WAV at that rate,
falling back to the Whisper-friendly 16 kHz constant when the backend
does not expose a usable rate. STT providers accept standard WAV rates,
so downstream transcription is unaffected.
This commit is contained in:
Brice 2026-07-09 14:54:31 +02:00 committed by Teknium
parent f98952b267
commit 2a75664c0c
2 changed files with 88 additions and 8 deletions

View file

@ -2038,3 +2038,66 @@ class TestPlayBeepVolumeWiring:
)
assert "beep_volume * 32767" in source
assert "_get_beep_volume()" in source
# ============================================================================
# Device-native input sample rate — mics that reject 16 kHz capture
# ============================================================================
class TestDefaultInputSamplerate:
def test_uses_device_default_rate(self):
from tools.voice_mode import _default_input_samplerate
sd = MagicMock()
sd.query_devices.return_value = {"default_samplerate": 44100.0}
assert _default_input_samplerate(sd) == 44100
def test_falls_back_when_query_fails(self):
from tools.voice_mode import SAMPLE_RATE, _default_input_samplerate
sd = MagicMock()
sd.query_devices.side_effect = RuntimeError("no device")
assert _default_input_samplerate(sd) == SAMPLE_RATE
def test_falls_back_on_non_numeric_rate(self):
from tools.voice_mode import SAMPLE_RATE, _default_input_samplerate
sd = MagicMock()
sd.query_devices.return_value = {"default_samplerate": None}
assert _default_input_samplerate(sd) == SAMPLE_RATE
def test_recorder_opens_stream_at_device_rate(self, mock_sd):
mock_sd.query_devices.return_value = {"default_samplerate": 48000.0}
mock_stream = MagicMock()
mock_sd.InputStream.return_value = mock_stream
from tools.voice_mode import AudioRecorder
recorder = AudioRecorder()
recorder.start()
assert recorder.is_recording is True
assert mock_sd.InputStream.call_args.kwargs["samplerate"] == 48000
def test_wav_written_at_capture_rate(self, mock_sd, temp_voice_dir):
np = pytest.importorskip("numpy")
mock_sd.query_devices.return_value = {"default_samplerate": 48000.0}
mock_stream = MagicMock()
mock_sd.InputStream.return_value = mock_stream
from tools.voice_mode import AudioRecorder
recorder = AudioRecorder()
recorder.start()
# 1 second of loud audio at the device rate (above RMS threshold)
frame = np.full((48000, 1), 1000, dtype="int16")
recorder._frames = [frame]
recorder._peak_rms = 1000
wav_path = recorder.stop()
assert wav_path is not None
with wave.open(wav_path, "rb") as wf:
assert wf.getframerate() == 48000

View file

@ -50,6 +50,22 @@ def _audio_available() -> bool:
return False
def _default_input_samplerate(sd) -> int:
"""Return the preferred capture rate for the default input device.
Falls back to the Whisper-friendly 16 kHz constant when the backend does
not expose a numeric default rate.
"""
try:
info = sd.query_devices(None, "input")
rate = info.get("default_samplerate") if isinstance(info, dict) else getattr(info, "default_samplerate", None)
if isinstance(rate, (int, float)) and rate > 0:
return int(round(rate))
except Exception:
pass
return SAMPLE_RATE
from hermes_constants import is_termux as _is_termux_environment
@ -521,6 +537,7 @@ class AudioRecorder:
self._frames: List[Any] = []
self._recording = False
self._start_time: float = 0.0
self._sample_rate: int = SAMPLE_RATE
# Silence detection state
self._has_spoken = False
self._speech_start: float = 0.0 # When speech attempt began
@ -693,7 +710,7 @@ class AudioRecorder:
stream = None
try:
stream = sd.InputStream(
samplerate=SAMPLE_RATE,
samplerate=self._sample_rate,
channels=CHANNELS,
dtype=DTYPE,
callback=_callback,
@ -727,7 +744,7 @@ class AudioRecorder:
or if a recording is already in progress.
"""
try:
_import_audio()
sd, _ = _import_audio()
except (ImportError, OSError) as e:
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
@ -749,13 +766,13 @@ class AudioRecorder:
self._peak_rms = 0
self._current_rms = 0
self._on_silence_stop = on_silence_stop
# Ensure the persistent stream is alive (no-op after first call).
self._sample_rate = _default_input_samplerate(sd)
self._ensure_stream()
with self._lock:
self._recording = True
logger.info("Voice recording started (rate=%d, channels=%d)", SAMPLE_RATE, CHANNELS)
logger.info("Voice recording started (rate=%d, channels=%d)", self._sample_rate, CHANNELS)
def _close_stream_with_timeout(self, timeout: float = 3.0) -> None:
"""Close the audio stream with a timeout to prevent CoreAudio hangs."""
@ -810,7 +827,7 @@ class AudioRecorder:
logger.info("Voice recording stopped (%.1fs, %d samples)", elapsed, len(audio_data))
# Skip very short recordings (< 0.3s of audio)
min_samples = int(SAMPLE_RATE * 0.3)
min_samples = int(self._sample_rate * 0.3)
if len(audio_data) < min_samples:
logger.debug("Recording too short (%d samples), discarding", len(audio_data))
return None
@ -822,7 +839,7 @@ class AudioRecorder:
self._peak_rms, SILENCE_RMS_THRESHOLD)
return None
return self._write_wav(audio_data)
return self._write_wav(audio_data, sample_rate=self._sample_rate)
def cancel(self) -> None:
"""Stop recording and discard all captured audio.
@ -849,7 +866,7 @@ class AudioRecorder:
# -- private helpers -----------------------------------------------------
@staticmethod
def _write_wav(audio_data) -> str:
def _write_wav(audio_data, *, sample_rate: int = SAMPLE_RATE) -> str:
"""Write numpy int16 audio data to a WAV file.
Returns the file path.
@ -861,7 +878,7 @@ class AudioRecorder:
with wave.open(wav_path, "wb") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.setframerate(sample_rate)
wf.writeframes(audio_data.tobytes())
file_size = os.path.getsize(wav_path)