* 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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user