mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
f98952b267
commit
2a75664c0c
2 changed files with 88 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue