From 2a75664c0c6b0e84b59ce99f9c3c21c453d5fa3a Mon Sep 17 00:00:00 2001 From: Brice Date: Thu, 9 Jul 2026 14:54:31 +0200 Subject: [PATCH] fix(voice): capture at the input device's native sample rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/tools/test_voice_mode.py | 63 ++++++++++++++++++++++++++++++++++ tools/voice_mode.py | 33 +++++++++++++----- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index a8d7ca387fcc..a88b12632f73 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -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 diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 60a56c6768ca..5b307909587a 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -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)