* fix(audio): resolve device by name to prevent index mismatch crash (#498) Audio device indices can shift between sessions when USB devices are replugged or virtual devices are added/removed. A saved device_index=24 (BRIO webcam) could later point to 'speech-dispatcher-dummy', causing PortAudio to attempt recording from a virtual device and triggering malloc corruption / SIGABRT. Fix has three parts: 1. Store and match by device name, not just index - Config already saves device_name alongside device_index - At recording time, _resolve_device_by_name() enumerates current devices and finds the one matching the saved name - Falls back to stored index, then system default (with warning log) - main.py now passes audio_device_name to SpeechRecognitionManager 2. Filter virtual devices from input device enumeration - _is_virtual_device() detects speech-dispatcher, dummy, null sink, monitor, and ALSA output devices - get_audio_input_devices() skips virtual devices - Settings dialog dropdown only shows real input devices - Debug logging in _record_audio also skips virtual devices 3. Validate resolved device has input channels (from flatpak branch) - Extra safety check: after resolving, verify maxInputChannels > 0 - Falls back to system default if resolved device is invalid - Brings the validation from feature/flatpak-packaging to main Closes #498 * style: apply black formatting to recognition_manager.py * test: add audio_device_name kwarg to mock assertion * test: add coverage for _is_virtual_device, _resolve_device_by_name, device name management * test: fix StopIteration in fallback test by using return_value for mock * ponytail: remove redundant validation and simplify _resolve_device_by_name - Remove ~20 lines of dead validation in _record_audio() — _resolve_device_by_name and _resolve_valid_input_device already check maxInputChannels and virtual devices - Simplify _resolve_device_by_name to just find index by name, then delegate all validation to _resolve_valid_input_device (which already does channel/virtual checks) - Update tests to match the simplified delegation pattern * style(speech): format device fallback warning * test(speech): cover audio device fallback edges * test(speech): cover reconnection device fallback --------- Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
8f32f01f0e
commit
3303d075a6
@@ -354,12 +354,16 @@ def main():
|
||||
stop_sound_guard_ms = saved_settings.get("stop_sound_guard_ms", 200)
|
||||
voice_commands_enabled = saved_settings.get("voice_commands_enabled") # None = auto
|
||||
audio_device_index = audio_settings.get("device_index", None)
|
||||
audio_device_name = audio_settings.get("device_name", None)
|
||||
|
||||
advanced_settings = config_manager.get_settings().get("advanced", {})
|
||||
|
||||
logger.info(f"Final settings: engine={engine}, language={language}, model={model_size}")
|
||||
if audio_device_index is not None:
|
||||
logger.info(f"Using audio device index={audio_device_index} (from saved config)")
|
||||
logger.info(
|
||||
f"Using audio device index={audio_device_index} "
|
||||
f"(name={audio_device_name}, from saved config)"
|
||||
)
|
||||
|
||||
# Initialize main components
|
||||
logger.info("Initializing Vocalinux...")
|
||||
@@ -375,6 +379,7 @@ def main():
|
||||
stop_sound_guard_ms=stop_sound_guard_ms,
|
||||
voice_commands_enabled=voice_commands_enabled,
|
||||
audio_device_index=audio_device_index,
|
||||
audio_device_name=audio_device_name,
|
||||
whispercpp_no_timestamps=advanced_settings.get("whispercpp_no_timestamps", True),
|
||||
whispercpp_no_context=advanced_settings.get("whispercpp_no_context", True),
|
||||
whispercpp_initial_prompt=advanced_settings.get("whispercpp_initial_prompt", ""),
|
||||
|
||||
@@ -180,9 +180,36 @@ def _preload_pywhispercpp_shared_libraries() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _is_virtual_device(device_name: str) -> bool:
|
||||
"""Check if a device name corresponds to a virtual audio device.
|
||||
|
||||
Virtual devices (e.g. speech-dispatcher-dummy, PulseAudio monitors,
|
||||
PipeWire null sinks) cannot be used for recording and may cause crashes
|
||||
if PortAudio tries to open them.
|
||||
|
||||
Args:
|
||||
device_name: The device name to check.
|
||||
|
||||
Returns:
|
||||
True if the device appears to be virtual, False otherwise.
|
||||
"""
|
||||
if not device_name:
|
||||
return False
|
||||
name_lower = device_name.lower()
|
||||
virtual_patterns = [
|
||||
"speech-dispatcher",
|
||||
"speechdispatcher",
|
||||
"dummy",
|
||||
"null sink",
|
||||
"monitor of",
|
||||
"alsa_output", # ALSA output devices exposed as monitors
|
||||
]
|
||||
return any(pattern in name_lower for pattern in virtual_patterns)
|
||||
|
||||
|
||||
def get_audio_input_devices() -> list:
|
||||
"""
|
||||
Get a list of available audio input devices.
|
||||
Get a list of available audio input devices, excluding virtual devices.
|
||||
|
||||
Returns:
|
||||
List of tuples: (device_index, device_name, is_default)
|
||||
@@ -206,6 +233,10 @@ def get_audio_input_devices() -> list:
|
||||
# Only include devices that have input channels
|
||||
if info.get("maxInputChannels", 0) > 0:
|
||||
name = info.get("name", f"Device {i}")
|
||||
# Skip virtual devices that can cause crashes
|
||||
if _is_virtual_device(name):
|
||||
logger.debug(f"Filtering virtual device [{i}]: {name}")
|
||||
continue
|
||||
is_default = i == default_input_device
|
||||
devices.append((i, name, is_default))
|
||||
except (IOError, OSError):
|
||||
@@ -220,6 +251,37 @@ def get_audio_input_devices() -> list:
|
||||
return devices
|
||||
|
||||
|
||||
def _resolve_device_by_name(
|
||||
audio, device_name: Optional[str], fallback_index: Optional[int] = None
|
||||
) -> Optional[int]:
|
||||
"""Resolve a device index by name, falling back to index, then system default.
|
||||
|
||||
Device indices shift when USB devices are replugged or virtual devices are
|
||||
added/removed. Name matching is more stable.
|
||||
"""
|
||||
if not device_name:
|
||||
return _resolve_valid_input_device(audio, fallback_index)
|
||||
|
||||
try:
|
||||
device_count = int(audio.get_device_count())
|
||||
except (IOError, OSError, TypeError, ValueError, AttributeError):
|
||||
return _resolve_valid_input_device(audio, fallback_index)
|
||||
|
||||
# ponytail: just find the index by name, delegate validation to _resolve_valid_input_device
|
||||
for i in range(device_count):
|
||||
try:
|
||||
info = audio.get_device_info_by_index(i)
|
||||
except (IOError, OSError, TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
if isinstance(info, dict) and info.get("name", "") == device_name:
|
||||
return _resolve_valid_input_device(audio, i)
|
||||
|
||||
logger.warning(
|
||||
f"Audio device '{device_name}' not found, falling back to index {fallback_index}"
|
||||
)
|
||||
return _resolve_valid_input_device(audio, fallback_index)
|
||||
|
||||
|
||||
def _resolve_valid_input_device(audio, preferred_index: Optional[int] = None) -> Optional[int]:
|
||||
"""Resolve a valid audio input device, skipping output-only devices (e.g. HDMI).
|
||||
|
||||
@@ -765,6 +827,7 @@ class SpeechRecognitionManager:
|
||||
|
||||
# Audio device selection (None means use system default)
|
||||
self.audio_device_index = kwargs.get("audio_device_index", None)
|
||||
self.audio_device_name = kwargs.get("audio_device_name", None)
|
||||
|
||||
# whisper.cpp advanced parameters
|
||||
self.whispercpp_no_timestamps = kwargs.get("whispercpp_no_timestamps", True)
|
||||
@@ -2026,21 +2089,30 @@ class SpeechRecognitionManager:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def set_audio_device(self, device_index: Optional[int]):
|
||||
def set_audio_device(self, device_index: Optional[int], device_name: Optional[str] = None):
|
||||
"""
|
||||
Set the audio input device to use.
|
||||
|
||||
Args:
|
||||
device_index: The device index to use, or None for system default
|
||||
device_name: The device name (for stable re-resolution on next recording)
|
||||
"""
|
||||
if device_index != self.audio_device_index:
|
||||
logger.info(f"Audio device changed from {self.audio_device_index} to {device_index}")
|
||||
self.audio_device_index = device_index
|
||||
if device_index != self.audio_device_index or device_name != self.audio_device_name:
|
||||
logger.info(
|
||||
f"Audio device changed from {self.audio_device_index} "
|
||||
f"to {device_index} (name: {device_name})"
|
||||
)
|
||||
self.audio_device_index = device_index
|
||||
self.audio_device_name = device_name
|
||||
|
||||
def get_audio_device(self) -> Optional[int]:
|
||||
"""Get the currently configured audio device index."""
|
||||
return self.audio_device_index
|
||||
|
||||
def get_audio_device_name(self) -> Optional[str]:
|
||||
"""Get the currently configured audio device name."""
|
||||
return self.audio_device_name
|
||||
|
||||
def get_last_audio_level(self) -> float:
|
||||
"""Get the last recorded audio level (0-100)."""
|
||||
return self._last_audio_level
|
||||
@@ -2199,8 +2271,14 @@ class SpeechRecognitionManager:
|
||||
self._pyaudio_instance = pyaudio.PyAudio()
|
||||
audio = self._pyaudio_instance
|
||||
|
||||
# Resolve a valid input device — skip output-only devices (e.g. HDMI)
|
||||
resolved_device_index = _resolve_valid_input_device(audio, self.audio_device_index)
|
||||
# Resolve the input device by name first (indices can shift between
|
||||
# sessions due to USB replugging or virtual devices being added).
|
||||
# Fall back to the stored index, then to the system default.
|
||||
resolved_device_index = _resolve_device_by_name(
|
||||
audio, self.audio_device_name, self.audio_device_index
|
||||
)
|
||||
if resolved_device_index is None:
|
||||
resolved_device_index = _resolve_valid_input_device(audio, None)
|
||||
if resolved_device_index is None:
|
||||
logger.error("No audio input devices found with input channels.")
|
||||
logger.error(
|
||||
@@ -2211,15 +2289,16 @@ class SpeechRecognitionManager:
|
||||
self._update_state(RecognitionState.ERROR)
|
||||
return
|
||||
|
||||
# Log available devices for debugging
|
||||
# Log available devices for debugging (skip virtual devices)
|
||||
logger.debug("Available audio input devices:")
|
||||
for i in range(audio.get_device_count()):
|
||||
try:
|
||||
info = audio.get_device_info_by_index(i)
|
||||
if info.get("maxInputChannels", 0) > 0:
|
||||
logger.debug(
|
||||
f" [{i}] {info.get('name')} (inputs: {info.get('maxInputChannels')})"
|
||||
)
|
||||
name = info.get("name", "")
|
||||
if _is_virtual_device(name):
|
||||
continue
|
||||
logger.debug(f" [{i}] {name} (inputs: {info.get('maxInputChannels')})")
|
||||
except (IOError, OSError):
|
||||
continue
|
||||
|
||||
@@ -2658,6 +2737,7 @@ class SpeechRecognitionManager:
|
||||
vad_sensitivity: Optional[int] = None,
|
||||
silence_timeout: Optional[float] = None,
|
||||
audio_device_index: Optional[int] = None,
|
||||
audio_device_name: Optional[str] = None,
|
||||
force_download: bool = True,
|
||||
**kwargs, # Allow for future expansion
|
||||
):
|
||||
@@ -2671,10 +2751,13 @@ class SpeechRecognitionManager:
|
||||
vad_sensitivity: New VAD sensitivity (for VOSK).
|
||||
silence_timeout: New silence timeout (for VOSK).
|
||||
audio_device_index: Audio input device index (None for default, -1 to clear).
|
||||
audio_device_name: Audio device name for stable re-resolution.
|
||||
force_download: If True, download missing models (default: True for UI-triggered reconfigures).
|
||||
"""
|
||||
logger.info(
|
||||
f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, language={language}, vad={vad_sensitivity}, silence={silence_timeout}, audio_device={audio_device_index}"
|
||||
f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, "
|
||||
f"language={language}, vad={vad_sensitivity}, silence={silence_timeout}, "
|
||||
f"audio_device={audio_device_index}, audio_device_name={audio_device_name}"
|
||||
)
|
||||
|
||||
restart_needed = False
|
||||
@@ -2704,8 +2787,11 @@ class SpeechRecognitionManager:
|
||||
if audio_device_index is not None:
|
||||
if audio_device_index == -1:
|
||||
self.audio_device_index = None
|
||||
self.audio_device_name = None
|
||||
else:
|
||||
self.audio_device_index = audio_device_index
|
||||
if audio_device_name is not None:
|
||||
self.audio_device_name = audio_device_name
|
||||
|
||||
if "voice_commands_enabled" in kwargs:
|
||||
self._voice_commands_preference = kwargs.get("voice_commands_enabled")
|
||||
@@ -2828,10 +2914,12 @@ class SpeechRecognitionManager:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing old audio stream: {e}")
|
||||
|
||||
# Resolve a valid input device — skip output-only devices (e.g. HDMI)
|
||||
resolved_device_index = _resolve_valid_input_device(
|
||||
audio_instance, self.audio_device_index
|
||||
# Resolve a valid input device — by name first, then by index
|
||||
resolved_device_index = _resolve_device_by_name(
|
||||
audio_instance, self.audio_device_name, self.audio_device_index
|
||||
)
|
||||
if resolved_device_index is None:
|
||||
resolved_device_index = _resolve_valid_input_device(audio_instance, None)
|
||||
if resolved_device_index is None:
|
||||
logger.error("Reconnection failed: no input devices available.")
|
||||
return False
|
||||
|
||||
@@ -3251,13 +3251,27 @@ For now, the engine has been reverted to VOSK."""
|
||||
self.audio_device_combo.append(str(device_index), label)
|
||||
|
||||
saved_device = self.config_manager.get_optional_int("audio", "device_index", None)
|
||||
saved_device_name = self.config_manager.get("audio", "device_name", None)
|
||||
|
||||
if saved_device is None:
|
||||
self.audio_device_combo.set_active_id("-1")
|
||||
else:
|
||||
if not self.audio_device_combo.set_active_id(str(saved_device)):
|
||||
logger.warning(f"Saved audio device {saved_device} no longer available")
|
||||
self.audio_device_combo.set_active_id("-1")
|
||||
# Try to match by saved device name first (more stable across reboots)
|
||||
matched = False
|
||||
if saved_device_name:
|
||||
for idx, name, _ in devices:
|
||||
if name == saved_device_name:
|
||||
self.audio_device_combo.set_active_id(str(idx))
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
# Fall back to stored index
|
||||
if not self.audio_device_combo.set_active_id(str(saved_device)):
|
||||
logger.warning(
|
||||
f"Saved audio device {saved_device} "
|
||||
f"(name: {saved_device_name}) no longer available"
|
||||
)
|
||||
self.audio_device_combo.set_active_id("-1")
|
||||
|
||||
logger.info(f"Found {len(devices)} audio input devices")
|
||||
|
||||
@@ -3288,9 +3302,9 @@ For now, the engine has been reverted to VOSK."""
|
||||
self.config_manager.save_settings()
|
||||
|
||||
if device_index == -1:
|
||||
self.speech_engine.set_audio_device(None)
|
||||
self.speech_engine.set_audio_device(None, None)
|
||||
else:
|
||||
self.speech_engine.set_audio_device(device_index)
|
||||
self.speech_engine.set_audio_device(device_index, device_name)
|
||||
|
||||
logger.info(f"Audio device changed to: [{device_index}] {device_name}")
|
||||
self.audio_test_status.set_markup(f"<i>Selected: {device_name}</i>")
|
||||
|
||||
@@ -231,6 +231,7 @@ class TestMainModule(unittest.TestCase):
|
||||
stop_sound_guard_ms=200,
|
||||
voice_commands_enabled=None,
|
||||
audio_device_index=None,
|
||||
audio_device_name=None,
|
||||
whispercpp_no_timestamps=True,
|
||||
whispercpp_no_context=True,
|
||||
whispercpp_initial_prompt="",
|
||||
|
||||
@@ -518,6 +518,39 @@ class TestSpeechRecognition(unittest.TestCase):
|
||||
self.assertEqual(manager.silence_timeout, 1.5)
|
||||
self.assertEqual(manager.audio_device_index, 2)
|
||||
|
||||
def test_init_with_device_name(self):
|
||||
"""Test initialization with audio_device_name kwarg."""
|
||||
manager = SpeechRecognitionManager(
|
||||
engine="vosk", audio_device_index=3, audio_device_name="USB Mic"
|
||||
)
|
||||
|
||||
self.assertEqual(manager.audio_device_index, 3)
|
||||
self.assertEqual(manager.get_audio_device_name(), "USB Mic")
|
||||
|
||||
def test_set_audio_device_with_name(self):
|
||||
"""Test set_audio_device stores both index and name."""
|
||||
manager = SpeechRecognitionManager(engine="vosk")
|
||||
|
||||
manager.set_audio_device(5, "BRIO Webcam")
|
||||
self.assertEqual(manager.get_audio_device(), 5)
|
||||
self.assertEqual(manager.get_audio_device_name(), "BRIO Webcam")
|
||||
|
||||
def test_set_audio_device_clears_name(self):
|
||||
"""Test set_audio_device(None, None) clears both."""
|
||||
manager = SpeechRecognitionManager(engine="vosk", audio_device_name="Mic")
|
||||
|
||||
manager.set_audio_device(None, None)
|
||||
self.assertIsNone(manager.get_audio_device())
|
||||
self.assertIsNone(manager.get_audio_device_name())
|
||||
|
||||
def test_reconfigure_with_device_name(self):
|
||||
"""Test reconfigure accepts audio_device_name."""
|
||||
manager = SpeechRecognitionManager(engine="vosk")
|
||||
|
||||
manager.reconfigure(audio_device_index=2, audio_device_name="Headset")
|
||||
self.assertEqual(manager.audio_device_index, 2)
|
||||
self.assertEqual(manager.get_audio_device_name(), "Headset")
|
||||
|
||||
|
||||
class TestModuleLevelFunctions(unittest.TestCase):
|
||||
"""Test module-level functions in recognition_manager."""
|
||||
@@ -572,6 +605,238 @@ class TestModuleLevelFunctions(unittest.TestCase):
|
||||
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
|
||||
devices = recognition_manager.get_audio_input_devices()
|
||||
|
||||
def test_get_audio_input_devices_skips_unreadable_device(self):
|
||||
"""Test unreadable devices are skipped during enumeration."""
|
||||
from vocalinux.speech_recognition import recognition_manager
|
||||
|
||||
mock_pyaudio_instance = MagicMock()
|
||||
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 1}
|
||||
mock_pyaudio_instance.get_device_count.return_value = 2
|
||||
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
|
||||
IOError("device disappeared"),
|
||||
{"name": "USB Mic", "maxInputChannels": 1},
|
||||
]
|
||||
|
||||
mock_pyaudio = MagicMock()
|
||||
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
|
||||
|
||||
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
|
||||
devices = recognition_manager.get_audio_input_devices()
|
||||
|
||||
self.assertEqual(devices, [(1, "USB Mic", True)])
|
||||
|
||||
def test_get_audio_input_devices_skips_output_only_device(self):
|
||||
"""Test output-only devices are skipped during enumeration."""
|
||||
from vocalinux.speech_recognition import recognition_manager
|
||||
|
||||
mock_pyaudio_instance = MagicMock()
|
||||
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 1}
|
||||
mock_pyaudio_instance.get_device_count.return_value = 2
|
||||
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
|
||||
{"name": "HDMI Output", "maxInputChannels": 0},
|
||||
{"name": "USB Mic", "maxInputChannels": 1},
|
||||
]
|
||||
|
||||
mock_pyaudio = MagicMock()
|
||||
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
|
||||
|
||||
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
|
||||
devices = recognition_manager.get_audio_input_devices()
|
||||
|
||||
self.assertEqual(devices, [(1, "USB Mic", True)])
|
||||
|
||||
def test_get_audio_input_devices_import_error(self):
|
||||
"""Test missing PyAudio returns an empty device list."""
|
||||
from vocalinux.speech_recognition import recognition_manager
|
||||
|
||||
with patch.dict(sys.modules, {"pyaudio": None}):
|
||||
devices = recognition_manager.get_audio_input_devices()
|
||||
|
||||
self.assertEqual(devices, [])
|
||||
|
||||
def test_get_audio_input_devices_pyaudio_error(self):
|
||||
"""Test PyAudio enumeration errors return an empty device list."""
|
||||
from vocalinux.speech_recognition import recognition_manager
|
||||
|
||||
mock_pyaudio = MagicMock()
|
||||
mock_pyaudio.PyAudio.side_effect = OSError("boom")
|
||||
|
||||
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
|
||||
devices = recognition_manager.get_audio_input_devices()
|
||||
|
||||
self.assertEqual(devices, [])
|
||||
|
||||
def test_is_virtual_device(self):
|
||||
"""Test _is_virtual_device detects known virtual device patterns."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _is_virtual_device
|
||||
|
||||
# Virtual devices
|
||||
self.assertTrue(_is_virtual_device("speech-dispatcher-dummy"))
|
||||
self.assertTrue(_is_virtual_device("speech-dispatcher-generic"))
|
||||
self.assertTrue(_is_virtual_device("Dummy Output"))
|
||||
self.assertTrue(_is_virtual_device("Null Sink"))
|
||||
self.assertTrue(_is_virtual_device("Monitor of Built-in Audio"))
|
||||
self.assertTrue(_is_virtual_device("alsa_output.pci-0000"))
|
||||
|
||||
# Real devices
|
||||
self.assertFalse(_is_virtual_device("BRIO Ultra HD Webcam"))
|
||||
self.assertFalse(_is_virtual_device("Built-in Microphone"))
|
||||
self.assertFalse(_is_virtual_device("USB Headset"))
|
||||
|
||||
# Edge cases
|
||||
self.assertFalse(_is_virtual_device(""))
|
||||
self.assertFalse(_is_virtual_device(None))
|
||||
|
||||
def test_is_virtual_device_case_insensitive(self):
|
||||
"""Test _is_virtual_device is case-insensitive."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _is_virtual_device
|
||||
|
||||
self.assertTrue(_is_virtual_device("SPEECH-DISPATCHER-DUMMY"))
|
||||
self.assertTrue(_is_virtual_device("NULL SINK"))
|
||||
self.assertTrue(_is_virtual_device("Monitor Of Something"))
|
||||
|
||||
def test_get_audio_input_devices_filters_virtual(self):
|
||||
"""Test that get_audio_input_devices excludes virtual devices."""
|
||||
from vocalinux.speech_recognition import recognition_manager
|
||||
|
||||
mock_pyaudio_instance = MagicMock()
|
||||
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 0}
|
||||
mock_pyaudio_instance.get_device_count.return_value = 3
|
||||
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
|
||||
{"name": "Real Mic", "maxInputChannels": 2},
|
||||
{"name": "speech-dispatcher-dummy", "maxInputChannels": 2},
|
||||
{"name": "USB Headset", "maxInputChannels": 1},
|
||||
]
|
||||
|
||||
mock_pyaudio = MagicMock()
|
||||
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
|
||||
|
||||
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
|
||||
devices = recognition_manager.get_audio_input_devices()
|
||||
|
||||
# Should only have 2 devices (virtual one filtered out)
|
||||
device_names = [d[1] for d in devices]
|
||||
self.assertIn("Real Mic", device_names)
|
||||
self.assertIn("USB Headset", device_names)
|
||||
self.assertNotIn("speech-dispatcher-dummy", device_names)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_found(self, mock_valid):
|
||||
"""Test _resolve_device_by_name finds a matching device and delegates validation."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = 1
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.get_device_count.return_value = 3
|
||||
mock_audio.get_device_info_by_index.side_effect = [
|
||||
{"name": "Built-in Mic", "maxInputChannels": 1},
|
||||
{"name": "BRIO Webcam", "maxInputChannels": 2},
|
||||
{"name": "speech-dispatcher-dummy", "maxInputChannels": 2},
|
||||
]
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, "BRIO Webcam")
|
||||
self.assertEqual(result, 1)
|
||||
mock_valid.assert_called_once_with(mock_audio, 1)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_not_found_with_fallback(self, mock_valid):
|
||||
"""Test _resolve_device_by_name falls back to index when name not found."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = 0
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.get_device_count.return_value = 2
|
||||
mock_audio.get_device_info_by_index.return_value = {
|
||||
"name": "Mic",
|
||||
"maxInputChannels": 1,
|
||||
}
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, "Missing Device", fallback_index=0)
|
||||
self.assertEqual(result, 0)
|
||||
mock_valid.assert_called_once_with(mock_audio, 0)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_skips_unreadable_device(self, mock_valid):
|
||||
"""Test _resolve_device_by_name skips devices that cannot be read."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = 1
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.get_device_count.return_value = 2
|
||||
mock_audio.get_device_info_by_index.side_effect = [
|
||||
OSError("device disappeared"),
|
||||
{"name": "USB Mic", "maxInputChannels": 1},
|
||||
]
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, "USB Mic")
|
||||
self.assertEqual(result, 1)
|
||||
mock_valid.assert_called_once_with(mock_audio, 1)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_ignores_non_dict_device_info(self, mock_valid):
|
||||
"""Test _resolve_device_by_name ignores malformed device info."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = 0
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.get_device_count.return_value = 1
|
||||
mock_audio.get_device_info_by_index.return_value = None
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, "USB Mic", fallback_index=0)
|
||||
self.assertEqual(result, 0)
|
||||
mock_valid.assert_called_once_with(mock_audio, 0)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_not_found_no_fallback(self, mock_valid):
|
||||
"""Test _resolve_device_by_name returns None when name not found and no fallback."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = None
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.get_device_count.return_value = 1
|
||||
mock_audio.get_device_info_by_index.return_value = {
|
||||
"name": "Mic",
|
||||
"maxInputChannels": 1,
|
||||
}
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, "Missing Device")
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_empty_name_with_fallback(self, mock_valid):
|
||||
"""Test _resolve_device_by_name with empty name uses fallback index."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = 0
|
||||
mock_audio = MagicMock()
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, None, fallback_index=0)
|
||||
self.assertEqual(result, 0)
|
||||
mock_valid.assert_called_once_with(mock_audio, 0)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_empty_name_no_fallback(self, mock_valid):
|
||||
"""Test _resolve_device_by_name with empty name and no fallback returns None."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = None
|
||||
mock_audio = MagicMock()
|
||||
result = _resolve_device_by_name(mock_audio, None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
|
||||
def test_resolve_device_by_name_get_count_error(self, mock_valid):
|
||||
"""Test _resolve_device_by_name handles get_device_count errors."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
|
||||
|
||||
mock_valid.return_value = 0
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.get_device_count.side_effect = IOError("boom")
|
||||
|
||||
result = _resolve_device_by_name(mock_audio, "Some Device", fallback_index=0)
|
||||
self.assertEqual(result, 0)
|
||||
mock_valid.assert_called_once_with(mock_audio, 0)
|
||||
|
||||
def test_show_notification(self):
|
||||
"""Test _show_notification helper function."""
|
||||
from vocalinux.speech_recognition.recognition_manager import _show_notification
|
||||
|
||||
@@ -238,6 +238,69 @@ class TestAudioReconnection:
|
||||
assert result is True
|
||||
assert manager._audio_stream == mock_stream
|
||||
|
||||
def test_attempt_audio_reconnection_falls_back_to_default_resolver(self):
|
||||
"""Test reconnection falls back when saved device name/index cannot resolve."""
|
||||
manager = _make_manager(engine="whisper_cpp", audio_device_name="Missing Mic")
|
||||
|
||||
mock_pyaudio_mod = MagicMock()
|
||||
mock_pyaudio_mod.paInt16 = 8
|
||||
mock_stream = MagicMock()
|
||||
mock_stream.read.return_value = b"\x00" * 1024
|
||||
mock_audio_instance = MagicMock()
|
||||
mock_audio_instance.get_default_input_device_info.return_value = {"index": 0}
|
||||
mock_audio_instance.open.return_value = mock_stream
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
||||
patch("time.sleep"),
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
||||
return_value=None,
|
||||
) as mock_resolve_name,
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device",
|
||||
return_value=1,
|
||||
) as mock_resolve_default,
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager._get_supported_channels",
|
||||
return_value=1,
|
||||
),
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager._get_supported_sample_rate",
|
||||
return_value=16000,
|
||||
),
|
||||
):
|
||||
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
||||
|
||||
assert result is True
|
||||
assert manager._audio_stream == mock_stream
|
||||
mock_resolve_name.assert_called_once_with(mock_audio_instance, "Missing Mic", None)
|
||||
mock_resolve_default.assert_called_once_with(mock_audio_instance, None)
|
||||
|
||||
def test_attempt_audio_reconnection_no_resolved_device(self):
|
||||
"""Test reconnection fails when no resolver finds an input device."""
|
||||
manager = _make_manager(engine="whisper_cpp", audio_device_name="Missing Mic")
|
||||
|
||||
mock_pyaudio_mod = MagicMock()
|
||||
mock_audio_instance = MagicMock()
|
||||
|
||||
with (
|
||||
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
|
||||
patch("time.sleep"),
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = manager._attempt_audio_reconnection(mock_audio_instance)
|
||||
|
||||
assert result is False
|
||||
mock_audio_instance.open.assert_not_called()
|
||||
|
||||
def test_attempt_audio_reconnection_max_attempts(self):
|
||||
"""Test reconnection stops after max attempts."""
|
||||
manager = _make_manager(engine="whisper_cpp")
|
||||
|
||||
Reference in New Issue
Block a user