fix: auto-detect audio sample rate for device compatibility (#262) (#263)

* fix: auto-detect audio sample rate for device compatibility (#262)

- Add _get_supported_sample_rate() helper function
- Tests device default rate first, then common rates (48k, 44.1k, 32k, 22k, 16k, 8k)
- Fixes 'Invalid sample rate' error for devices like Vocaster One (48kHz only)
- Update test_audio_input(), _record_audio(), and _attempt_audio_reconnection()
- Return detected sample_rate in test_audio_input() result dict

* fix: import pyaudio inside _get_supported_sample_rate

* fix: resample audio to 16kHz for Vosk/Whisper compatibility

- Store detected sample rate in self._capture_sample_rate
- Resample audio chunks to 16kHz before appending to buffer (linear interpolation)
- Initialize _capture_sample_rate to 16000 in __init__
- Update both _record_audio() and _attempt_audio_reconnection()

This ensures devices like Vocaster One (48kHz only) can capture audio
and speech recognition engines (Vosk/Whisper) receive 16kHz as expected.

* style: fix black formatting

* feat(ui): show sample rate info in audio test results

- Display detected sample rate in test results (e.g., '48kHz → 16kHz auto')
- Remove redundant numpy import in _record_audio function
- Helps users understand that non-16kHz devices are supported
This commit is contained in:
Jatin K Malik
2026-02-25 00:01:44 -08:00
committed by GitHub
parent f068fbf8d7
commit 60989b3ee1
2 changed files with 115 additions and 4 deletions
@@ -101,6 +101,85 @@ def get_audio_input_devices() -> list:
return devices
def _get_supported_sample_rate(audio, device_index: int, channels: int = 1) -> int:
"""
Get a supported sample rate for the audio device.
Some audio devices (like Vocaster One) only support specific sample rates
(e.g., 48kHz) and will fail with the default 16kHz. This function tests
common sample rates and returns the highest supported one.
Args:
audio: PyAudio instance
device_index: The device index to test
channels: Number of channels (default 1)
Returns:
int: A supported sample rate, defaulting to 16000 if none work
"""
import pyaudio
FORMAT = pyaudio.paInt16
CHUNK = 1024
# Common sample rates to try, ordered from highest to lowest quality
COMMON_RATES = [48000, 44100, 32000, 22050, 16000, 8000]
# First, try the device's default sample rate
try:
if device_index is not None:
device_info = audio.get_device_info_by_index(device_index)
else:
device_info = audio.get_default_input_device_info()
default_rate = int(device_info.get("defaultSampleRate", 0))
if default_rate > 0 and default_rate in COMMON_RATES:
# Test if the default rate actually works
try:
stream_kwargs = {
"format": FORMAT,
"channels": channels,
"rate": default_rate,
"input": True,
"frames_per_buffer": CHUNK,
}
if device_index is not None:
stream_kwargs["input_device_index"] = device_index
test_stream = audio.open(**stream_kwargs)
test_stream.close()
logger.debug(f"Using device default sample rate: {default_rate}Hz")
return default_rate
except (IOError, OSError):
logger.debug(f"Device default rate {default_rate}Hz failed, trying common rates")
except (IOError, OSError) as e:
logger.debug(f"Could not get device default rate: {e}")
# Try common sample rates in order of preference
for rate in COMMON_RATES:
try:
stream_kwargs = {
"format": FORMAT,
"channels": channels,
"rate": rate,
"input": True,
"frames_per_buffer": CHUNK,
}
if device_index is not None:
stream_kwargs["input_device_index"] = device_index
test_stream = audio.open(**stream_kwargs)
test_stream.close()
logger.debug(f"Found supported sample rate: {rate}Hz")
return rate
except (IOError, OSError):
continue
# Fallback to 16kHz if nothing works
logger.warning("Could not find supported sample rate, defaulting to 16000Hz")
return 16000
def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict:
"""
Test audio input from a device and return diagnostic information.
@@ -137,7 +216,6 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict:
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
audio = pyaudio.PyAudio()
@@ -155,6 +233,10 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict:
audio.terminate()
return result
# Detect supported sample rate for this device
RATE = _get_supported_sample_rate(audio, device_index, CHANNELS)
result["sample_rate"] = RATE
# Open stream
try:
stream_kwargs = {
@@ -409,6 +491,7 @@ class SpeechRecognitionManager:
self._last_audio_error_time = 0
self._audio_stream = None
self._pyaudio_instance = None
self._capture_sample_rate = 16000 # Default, updated when device is opened
# Create models directory if it doesn't exist
os.makedirs(MODELS_DIR, exist_ok=True)
@@ -1435,7 +1518,6 @@ class SpeechRecognitionManager:
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
# Initialize PyAudio with reconnection support
self._pyaudio_instance = pyaudio.PyAudio()
@@ -1453,6 +1535,11 @@ class SpeechRecognitionManager:
except (IOError, OSError):
continue
# Detect supported sample rate for the selected device
RATE = _get_supported_sample_rate(audio, self.audio_device_index, CHANNELS)
self._capture_sample_rate = RATE
logger.info(f"Using sample rate: {RATE}Hz")
# Open microphone stream with optional device selection and reconnection logic
stream_kwargs = {
"format": FORMAT,
@@ -1519,6 +1606,19 @@ class SpeechRecognitionManager:
logger.info(f"Buffer trimmed by {remove_count} chunks")
data = stream.read(CHUNK, exception_on_overflow=False)
# Resample to 16kHz if capturing at non-16kHz for Vosk/Whisper compatibility
if self._capture_sample_rate != 16000:
audio_array = np.frombuffer(data, dtype=np.int16)
resample_ratio = 16000 / self._capture_sample_rate
resampled_length = int(len(audio_array) * resample_ratio)
resampled = np.interp(
np.linspace(0, len(audio_array), resampled_length),
np.arange(len(audio_array)),
audio_array,
).astype(np.int16)
data = resampled.tobytes()
self.audio_buffer.append(data)
# Simple Voice Activity Detection (VAD)
@@ -1817,7 +1917,11 @@ class SpeechRecognitionManager:
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
# Detect supported sample rate for the device
RATE = _get_supported_sample_rate(audio_instance, self.audio_device_index, CHANNELS)
self._capture_sample_rate = RATE
logger.debug(f"Reconnecting with sample rate: {RATE}Hz")
stream_kwargs = {
"format": FORMAT,
+8 -1
View File
@@ -2049,14 +2049,21 @@ For now, the engine has been reverted to VOSK."""
if result.get("success"):
max_level = result.get("max_amplitude", 0)
has_signal = result.get("has_signal", False)
sample_rate = result.get("sample_rate", 16000)
level_percent = min(100, (max_level / 327.68))
self.audio_level_bar.set_value(level_percent)
# Build sample rate info string
if sample_rate == 16000:
rate_info = "(16kHz native)"
else:
rate_info = f"({sample_rate // 1000}kHz → 16kHz auto)"
if has_signal:
self.audio_test_status.set_markup(
f"<span foreground='#26a269'>✓ Audio detected!</span> "
f"Peak level: {level_percent:.0f}%"
f"Peak: {level_percent:.0f}% {rate_info}"
)
else:
self.audio_test_status.set_markup(