* fix(audio): prevent Bluetooth mic probe heap corruption Open PortAudio streams only once when negotiating capture format, and always stop_stream() before close(). Separate channel/rate probing was aborting on Bluetooth SCO devices (e.g. FreeBuds) with malloc corruption. Fixes #567 * refactor(audio): open capture stream once instead of probe-close-reopen Adversarial review of the first fix found it still closed the probe stream and immediately reopened the device — the same rapid open/close pattern blamed for the Bluetooth SCO heap corruption, just fewer cycles. _open_capture_stream() now returns the successfully negotiated stream and all three callers (mic test, recording, reconnect) use it directly, so each capture session performs exactly one PortAudio open. Also drops the dead known_working_rate parameter. * test(audio): cover negotiation fallback paths for codecov patch Add tests for mic-test, recording, and reconnect fallback branches when _open_capture_stream returns no stream, plus Bluetooth settle-on-failure and empty-read reconnect cleanup. Brings patch diff coverage above 80%.
1311 lines
55 KiB
Python
1311 lines
55 KiB
Python
"""
|
|
Extra tests for recognition_manager.py to increase coverage.
|
|
|
|
Focuses on uncovered lines in:
|
|
- _init_vosk() error handling and model path resolution
|
|
- _init_whisper() model validation and error paths
|
|
- _init_whispercpp() backend detection and GPU fallback
|
|
- Download functions with progress tracking and error handling
|
|
- Transcription with model lock and error conditions
|
|
- start_recognition() and stop_recognition() flows
|
|
- Audio device detection and sample rate negotiation
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock, Mock, call, patch
|
|
|
|
import pytest
|
|
|
|
from vocalinux.common_types import RecognitionState
|
|
from vocalinux.speech_recognition.recognition_manager import (
|
|
SpeechRecognitionManager,
|
|
_filter_non_speech,
|
|
_get_supported_channels,
|
|
_get_supported_sample_rate,
|
|
_is_bluetooth_device,
|
|
_open_capture_stream,
|
|
_safe_close_stream,
|
|
get_audio_input_devices,
|
|
test_audio_input,
|
|
)
|
|
|
|
|
|
def _make_manager(engine="whisper_cpp", **kw):
|
|
"""Helper to create a manager with all init methods patched."""
|
|
with patch.object(SpeechRecognitionManager, "_init_vosk"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whisper"):
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
return SpeechRecognitionManager(
|
|
engine=engine, model_size="small", language="en-us", defer_download=True, **kw
|
|
)
|
|
|
|
|
|
class TestAudioDeviceDetection(unittest.TestCase):
|
|
"""Test audio device enumeration functions."""
|
|
|
|
def test_is_bluetooth_device_detection(self):
|
|
"""Bluetooth headset/mic names should be recognized (Issue #567)."""
|
|
assert _is_bluetooth_device("Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3")
|
|
assert _is_bluetooth_device("bluez_source.XX_XX_XX")
|
|
assert _is_bluetooth_device("Hands-Free AG Speech")
|
|
assert not _is_bluetooth_device("WH-1000XM5 Headset") # no bluetooth marker
|
|
assert not _is_bluetooth_device("USB Audio Device")
|
|
assert not _is_bluetooth_device(None)
|
|
assert not _is_bluetooth_device("")
|
|
|
|
def test_safe_close_stream_stops_before_close(self):
|
|
"""PortAudio streams must be stopped before close to avoid heap corruption."""
|
|
mock_stream = MagicMock()
|
|
_safe_close_stream(mock_stream)
|
|
mock_stream.stop_stream.assert_called_once()
|
|
mock_stream.close.assert_called_once()
|
|
# stop must happen before close
|
|
assert mock_stream.mock_calls.index(mock.call.stop_stream()) < mock_stream.mock_calls.index(
|
|
mock.call.close()
|
|
)
|
|
|
|
def test_safe_close_stream_tolerates_errors(self):
|
|
"""Cleanup must not raise even if stop/close fail."""
|
|
mock_stream = MagicMock()
|
|
mock_stream.stop_stream.side_effect = OSError("already stopped")
|
|
mock_stream.close.side_effect = OSError("already closed")
|
|
_safe_close_stream(mock_stream) # should not raise
|
|
_safe_close_stream(None) # should not raise
|
|
|
|
def test_open_capture_stream_returns_live_stream(self):
|
|
"""Negotiation must return the opened stream, never close-and-reopen."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "USB Mic",
|
|
"defaultSampleRate": 48000,
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 0)
|
|
assert channels == 1
|
|
assert rate == 48000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_count == 1
|
|
# The negotiated stream is handed to the caller live.
|
|
mock_stream.stop_stream.assert_not_called()
|
|
mock_stream.close.assert_not_called()
|
|
|
|
def test_open_capture_stream_bluetooth_single_open(self):
|
|
"""Bluetooth SCO capture must be opened exactly once (Issue #567).
|
|
|
|
The previous channels-then-rate-then-real-open flow opened the SCO
|
|
capture stream three times in under a second, which aborts with
|
|
malloc heap corruption on devices like the HUAWEI FreeBuds Pro 3.
|
|
"""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3",
|
|
"defaultSampleRate": 16000,
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 14)
|
|
assert channels == 1
|
|
assert rate == 16000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_count == 1
|
|
|
|
def test_open_capture_stream_mono_device_never_probes_stereo(self):
|
|
"""Mono-only devices must never be opened with 2 channels.
|
|
|
|
Opening with more channels than the device supports is itself a
|
|
PortAudio/ALSA heap-corruption trigger.
|
|
"""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("cannot open")
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "Bluetooth internal capture stream",
|
|
"defaultSampleRate": 16000,
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
with patch("vocalinux.speech_recognition.recognition_manager.time.sleep"):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 14)
|
|
assert stream is None
|
|
assert (channels, rate) == (1, 16000)
|
|
assert all(call.kwargs.get("channels") == 1 for call in mock_audio.open.call_args_list)
|
|
|
|
def test_open_capture_stream_failure_returns_none_stream(self):
|
|
"""Total negotiation failure returns (1, 16000, None) for caller fallback."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("no device")
|
|
mock_audio.get_device_info_by_index.side_effect = IOError("gone")
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 3)
|
|
assert (channels, rate, stream) == (1, 16000, None)
|
|
|
|
def test_test_audio_input_single_portaudio_open(self):
|
|
"""The mic test must perform exactly ONE PortAudio open (Issue #567)."""
|
|
from vocalinux.speech_recognition.recognition_manager import test_audio_input
|
|
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00\x01" * 1024
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"index": 14,
|
|
"name": "Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3",
|
|
"defaultSampleRate": 16000,
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
# Other test modules poison sys.modules["numpy"] with a MagicMock
|
|
# at import time; drop it so test_audio_input gets real numpy.
|
|
# patch.dict restores the original sys.modules afterwards.
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
del sys.modules["numpy"]
|
|
result = test_audio_input(device_index=14, duration=0.1)
|
|
|
|
assert result["success"] is True
|
|
assert mock_audio.open.call_count == 1
|
|
# The stream that was read from is the negotiated stream itself.
|
|
assert mock_stream.read.called
|
|
mock_stream.stop_stream.assert_called_once()
|
|
mock_stream.close.assert_called_once()
|
|
|
|
def test_test_audio_input_negotiation_fallback_open(self):
|
|
"""When negotiation returns no stream, mic test falls back to a plain open."""
|
|
mock_stream = MagicMock()
|
|
mock_stream.read.return_value = b"\x00\x01" * 1024
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"index": 14,
|
|
"name": "Bluetooth internal capture stream",
|
|
"defaultSampleRate": 16000,
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
del sys.modules["numpy"]
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
):
|
|
result = test_audio_input(device_index=14, duration=0.1)
|
|
|
|
assert result["success"] is True
|
|
mock_audio.open.assert_called_once()
|
|
|
|
def test_test_audio_input_negotiation_and_fallback_both_fail(self):
|
|
"""When negotiation and fallback open both fail, return a clear error."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("device busy")
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"index": 14,
|
|
"name": "Bluetooth internal capture stream",
|
|
"defaultSampleRate": 16000,
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
):
|
|
result = test_audio_input(device_index=14, duration=0.1)
|
|
|
|
assert result["success"] is False
|
|
assert "Cannot open audio stream" in result["error"]
|
|
|
|
def test_get_supported_sample_rate_bluetooth_settle_on_failure(self):
|
|
"""Bluetooth devices should pause between failed sample-rate probes."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("busy")
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "Bluetooth internal capture stream",
|
|
"defaultSampleRate": 48000,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
with patch("vocalinux.speech_recognition.recognition_manager.time.sleep") as mock_sleep:
|
|
rate = _get_supported_sample_rate(mock_audio, 0, channels=1)
|
|
|
|
assert rate == 16000
|
|
assert mock_sleep.called
|
|
|
|
def test_open_capture_stream_logs_channel_rejection(self):
|
|
"""Invalid-channel errors during negotiation should be logged distinctly."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("[Errno -9998] Invalid number of channels")
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "USB Mic",
|
|
"defaultSampleRate": 48000,
|
|
"maxInputChannels": 2,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 0)
|
|
|
|
assert (channels, rate, stream) == (1, 16000, None)
|
|
assert mock_audio.open.call_count >= 1
|
|
|
|
def test_get_supported_channels_mono_success(self):
|
|
"""Test mono channel support detection."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels = _get_supported_channels(mock_audio, 0)
|
|
assert channels == 1
|
|
|
|
def test_get_supported_channels_stereo_fallback(self):
|
|
"""Test fallback to stereo when mono fails."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
# First call (mono) fails, second (stereo) succeeds
|
|
def open_side_effect(**kwargs):
|
|
if kwargs.get("channels") == 1:
|
|
raise IOError("invalid number of channels")
|
|
return mock_stream
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels = _get_supported_channels(mock_audio, 0)
|
|
assert channels == 2
|
|
|
|
def test_get_supported_channels_all_fail(self):
|
|
"""Test fallback to mono when all channels fail."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("unsupported operation")
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels = _get_supported_channels(mock_audio, None)
|
|
assert channels == 1
|
|
|
|
def test_get_supported_channels_48khz_only_device(self):
|
|
"""Test channel detection on 48kHz-only pro audio devices (Issue #340).
|
|
|
|
Professional audio interfaces (MUPRO, Vocaster, etc.) only support 48kHz
|
|
and reject 16kHz probes with misleading "Invalid number of channels" error.
|
|
The fix should use the device's defaultSampleRate for channel probing.
|
|
"""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
rate = kwargs.get("rate")
|
|
channels = kwargs.get("channels")
|
|
|
|
# Simulate 48kHz-only device: 16kHz fails, 48kHz succeeds
|
|
if rate == 16000:
|
|
raise IOError("[Errno -9998] Invalid number of channels")
|
|
elif rate == 48000 and channels == 1:
|
|
return mock_stream
|
|
else:
|
|
raise IOError("Unsupported configuration")
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels = _get_supported_channels(mock_audio, 0)
|
|
assert channels == 1
|
|
# Verify that it tried using the device's default rate (48000)
|
|
assert any(
|
|
call[1].get("rate") == 48000 for call in mock_audio.open.call_args_list
|
|
), "Should probe using device's defaultSampleRate (48000)"
|
|
|
|
def test_get_supported_channels_default_rate_fails_fallback(self):
|
|
"""Test fallback when device's default rate fails during channel probing."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
rate = kwargs.get("rate")
|
|
channels = kwargs.get("channels")
|
|
|
|
# Default rate (48000) fails, but 44100 works
|
|
if rate == 48000:
|
|
raise IOError("Device busy")
|
|
elif rate == 44100 and channels == 1:
|
|
return mock_stream
|
|
else:
|
|
raise IOError("Unsupported")
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels = _get_supported_channels(mock_audio, 0)
|
|
assert channels == 1
|
|
|
|
def test_get_supported_channels_no_device_info(self):
|
|
"""Test channel probing when device info is unavailable."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
# Works with 44100Hz mono
|
|
if kwargs.get("rate") == 44100 and kwargs.get("channels") == 1:
|
|
return mock_stream
|
|
raise IOError("Unsupported")
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_device_info_by_index.side_effect = IOError("Device not found")
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels = _get_supported_channels(mock_audio, 0)
|
|
assert channels == 1 # Should fallback through common rates and find 44100
|
|
|
|
def test_get_supported_sample_rate_default_rate_works(self):
|
|
"""Test using device's default sample rate."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
rate = _get_supported_sample_rate(mock_audio, 0, 1)
|
|
assert rate == 48000
|
|
|
|
def test_get_supported_sample_rate_default_rate_fails(self):
|
|
"""Test fallback from device default rate."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
if kwargs.get("rate") == 48000:
|
|
raise IOError("unsupported rate")
|
|
return mock_stream
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
rate = _get_supported_sample_rate(mock_audio, 0, 1)
|
|
assert rate == 44100 # First fallback rate
|
|
|
|
def test_get_supported_sample_rate_all_fail(self):
|
|
"""Test fallback to default rate when all fail."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.side_effect = IOError("all fail")
|
|
mock_audio.get_device_info_by_index.side_effect = IOError("no device info")
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
rate = _get_supported_sample_rate(mock_audio, None, 1)
|
|
assert rate == 16000 # Default fallback
|
|
|
|
|
|
class TestRecordAudioNegotiationFallback(unittest.TestCase):
|
|
"""Cover _record_audio fallback when stream negotiation returns None."""
|
|
|
|
def test_record_audio_falls_back_when_negotiation_returns_none(self):
|
|
"""Negotiation failure must fall back to a plain PortAudio open."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager.should_record = True
|
|
manager.state = RecognitionState.LISTENING
|
|
manager.silence_timeout = 0.05
|
|
manager._silero_vad = None
|
|
|
|
mock_stream = MagicMock()
|
|
|
|
def _read_once(*_args, **_kwargs):
|
|
manager.should_record = False
|
|
return b"\x00" * 2048
|
|
|
|
mock_stream.read.side_effect = _read_once
|
|
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 1
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"index": 0,
|
|
"name": "test mic",
|
|
"maxInputChannels": 1,
|
|
}
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio.open.return_value = mock_stream
|
|
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._resolve_device_by_name",
|
|
return_value=0,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.play_error_sound",
|
|
),
|
|
):
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
del sys.modules["numpy"]
|
|
manager._record_audio()
|
|
|
|
mock_audio.open.assert_called_once()
|
|
|
|
|
|
class TestFilterNonSpeech(unittest.TestCase):
|
|
"""Test the _filter_non_speech function."""
|
|
|
|
def test_filter_non_speech_empty(self):
|
|
"""Test filtering empty string."""
|
|
result = _filter_non_speech("")
|
|
assert result == ""
|
|
|
|
def test_filter_non_speech_normal(self):
|
|
"""Test filtering normal text."""
|
|
result = _filter_non_speech("hello world")
|
|
assert result == "hello world"
|
|
|
|
def test_filter_non_speech_with_special_tokens(self):
|
|
"""Test filtering text with special tokens."""
|
|
result = _filter_non_speech("[BLANK_AUDIO]")
|
|
assert result == ""
|
|
|
|
def test_filter_non_speech_mixed(self):
|
|
"""Test filtering mixed content."""
|
|
result = _filter_non_speech("hello [BLANK_AUDIO] world")
|
|
assert "hello" in result or result == ""
|
|
|
|
|
|
class TestVoskInitialization(unittest.TestCase):
|
|
"""Test VOSK engine initialization."""
|
|
|
|
def test_init_vosk_model_not_found_deferred(self):
|
|
"""Test VOSK initialization with deferred download."""
|
|
manager = _make_manager(engine="vosk")
|
|
manager.language = "en-us"
|
|
manager._defer_download = True
|
|
manager.model_size = "small"
|
|
|
|
mock_vosk = MagicMock()
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.VOSK_MODEL_INFO",
|
|
{
|
|
"small": {"languages": {"en-us": "model-name"}},
|
|
"medium": {"languages": {"en-us": "model-name"}},
|
|
"large": {"languages": {"en-us": "model-name"}},
|
|
},
|
|
):
|
|
with patch("os.path.exists", return_value=False):
|
|
with patch.object(manager, "_get_vosk_model_path", return_value="/fake/path"):
|
|
with patch.dict("sys.modules", {"vosk": mock_vosk}):
|
|
manager._init_vosk()
|
|
assert manager._model_initialized is False
|
|
|
|
def test_init_vosk_import_error(self):
|
|
"""Test VOSK initialization when import fails."""
|
|
manager = _make_manager(engine="vosk")
|
|
manager.language = "en-us"
|
|
manager.model_size = "small"
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.VOSK_MODEL_INFO",
|
|
{
|
|
"small": {"languages": {"en-us": "model-name"}},
|
|
"medium": {"languages": {"en-us": "model-name"}},
|
|
"large": {"languages": {"en-us": "model-name"}},
|
|
},
|
|
):
|
|
with patch.object(manager, "_get_vosk_model_path", return_value="/fake/path"):
|
|
with patch("os.path.exists", return_value=False):
|
|
with patch.dict("sys.modules", {"vosk": None}):
|
|
with pytest.raises(ImportError):
|
|
manager._init_vosk()
|
|
assert manager.state == RecognitionState.ERROR
|
|
|
|
def test_init_vosk_preinstalled_model(self):
|
|
"""Test VOSK initialization with pre-installed model."""
|
|
manager = _make_manager(engine="vosk")
|
|
manager.language = "en-us"
|
|
manager.model_size = "small"
|
|
|
|
mock_vosk = MagicMock()
|
|
mock_model = MagicMock()
|
|
mock_recognizer = MagicMock()
|
|
mock_vosk.Model = MagicMock(return_value=mock_model)
|
|
mock_vosk.KaldiRecognizer = MagicMock(return_value=mock_recognizer)
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.VOSK_MODEL_INFO",
|
|
{
|
|
"small": {"languages": {"en-us": "model-name"}},
|
|
"medium": {"languages": {"en-us": "model-name"}},
|
|
"large": {"languages": {"en-us": "model-name"}},
|
|
},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.SYSTEM_MODELS_DIRS",
|
|
["/usr/share/vocalinux"],
|
|
):
|
|
with patch.object(
|
|
manager, "_get_vosk_model_path", return_value="/usr/share/vocalinux/model"
|
|
):
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch.dict("sys.modules", {"vosk": mock_vosk}):
|
|
manager._init_vosk()
|
|
assert manager._model_initialized is True
|
|
assert manager.model == mock_model
|
|
|
|
|
|
class TestWhisperInitialization(unittest.TestCase):
|
|
"""Test Whisper engine initialization."""
|
|
|
|
def test_init_whisper_invalid_model_size(self):
|
|
"""Test Whisper with invalid model size."""
|
|
manager = _make_manager(engine="whisper")
|
|
manager.model_size = "invalid"
|
|
manager._defer_download = True
|
|
|
|
mock_whisper = MagicMock()
|
|
mock_torch = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"whisper": mock_whisper, "torch": mock_torch}):
|
|
with patch("os.path.exists", return_value=False):
|
|
manager._init_whisper()
|
|
assert manager.model_size == "base" # Should be corrected
|
|
assert manager._model_initialized is False
|
|
|
|
def test_init_whisper_model_exists(self):
|
|
"""Test Whisper when model already exists."""
|
|
manager = _make_manager(engine="whisper")
|
|
manager.model_size = "tiny"
|
|
|
|
mock_whisper = MagicMock()
|
|
mock_torch = MagicMock()
|
|
mock_model = MagicMock()
|
|
mock_whisper.load_model.return_value = mock_model
|
|
mock_torch.cuda.is_available.return_value = False
|
|
|
|
with patch.dict("sys.modules", {"whisper": mock_whisper, "torch": mock_torch}):
|
|
with patch("os.path.exists", return_value=True):
|
|
manager._init_whisper()
|
|
assert manager._model_initialized is True
|
|
assert manager.model == mock_model
|
|
|
|
def test_init_whisper_import_error(self):
|
|
"""Test Whisper initialization when import fails."""
|
|
manager = _make_manager(engine="whisper")
|
|
|
|
with patch.dict("sys.modules", {"whisper": None, "torch": None}):
|
|
with pytest.raises(ImportError):
|
|
manager._init_whisper()
|
|
assert manager.state == RecognitionState.ERROR
|
|
|
|
def test_init_whisper_runtime_error(self):
|
|
"""Test Whisper initialization with runtime error."""
|
|
manager = _make_manager(engine="whisper")
|
|
manager._defer_download = False
|
|
|
|
mock_whisper = MagicMock()
|
|
mock_torch = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"whisper": mock_whisper, "torch": mock_torch}):
|
|
with patch("os.path.exists", return_value=False):
|
|
with patch.object(
|
|
manager, "_download_whisper_model", side_effect=RuntimeError("Download failed")
|
|
):
|
|
with pytest.raises(RuntimeError):
|
|
manager._init_whisper()
|
|
|
|
|
|
class TestWhispercppInitialization(unittest.TestCase):
|
|
"""Test whisper.cpp engine initialization."""
|
|
|
|
def test_init_whispercpp_invalid_model_size(self):
|
|
"""Test whisper.cpp with invalid model size."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager.model_size = "invalid"
|
|
manager._defer_download = True
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO",
|
|
{"tiny": {}, "base": {}},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value="/fake/path",
|
|
):
|
|
with patch("os.path.exists", return_value=False):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
},
|
|
):
|
|
manager._init_whispercpp()
|
|
assert manager.model_size == "tiny" # Should be corrected
|
|
assert manager._model_initialized is False
|
|
|
|
def test_init_whispercpp_gpu_fallback(self):
|
|
"""Test whisper.cpp GPU fallback to CPU."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
mock_model_success = MagicMock()
|
|
|
|
# Setup module hierarchy
|
|
model_class = MagicMock(
|
|
side_effect=[RuntimeError("16-bit storage not supported"), mock_model_success]
|
|
)
|
|
mock_pywhispercpp.Model = model_class
|
|
mock_pywhispercpp.model.Model = model_class
|
|
|
|
# Mock the imported functions from whispercpp_model_info
|
|
mock_psutil = MagicMock()
|
|
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value="/fake/model.bin",
|
|
):
|
|
with patch("os.path.getsize", return_value=100000000): # Mock file size
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
"psutil": mock_psutil,
|
|
},
|
|
):
|
|
# Patch the imports that happen inside _init_whispercpp
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_compute_backend",
|
|
return_value=(MagicMock(), "test"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="Vulkan",
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager._show_notification"
|
|
):
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
manager._init_whispercpp()
|
|
assert manager._model_initialized is True
|
|
|
|
def test_init_whispercpp_import_error(self):
|
|
"""Test whisper.cpp when import fails."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
with patch.dict("sys.modules", {"pywhispercpp": None, "pywhispercpp.model": None}):
|
|
with pytest.raises(ImportError):
|
|
manager._init_whispercpp()
|
|
assert manager.state == RecognitionState.ERROR
|
|
|
|
def test_init_whispercpp_model_file_not_found(self):
|
|
"""Test whisper.cpp when model file is missing."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager._defer_download = False
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO",
|
|
{"tiny": {"url": "http://example.com/model"}},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value="/fake/model.bin",
|
|
):
|
|
with patch("os.path.exists", return_value=False):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
},
|
|
):
|
|
with patch.object(
|
|
manager,
|
|
"_download_whispercpp_model",
|
|
side_effect=Exception("Download failed"),
|
|
):
|
|
with pytest.raises(Exception):
|
|
manager._init_whispercpp()
|
|
|
|
|
|
class TestWhispercppGpuDeviceSelection(unittest.TestCase):
|
|
"""Test whisper.cpp GPU device selection logic."""
|
|
|
|
def test_gpu_device_auto_select_discrete(self):
|
|
"""Test auto-selection of discrete GPU when configured as None."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
mock_model = MagicMock()
|
|
mock_pywhispercpp.Model = MagicMock(return_value=mock_model)
|
|
mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model)
|
|
|
|
mock_psutil = MagicMock()
|
|
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
|
|
|
|
vulkan_devices = [
|
|
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
|
|
{"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"},
|
|
]
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value="/fake/model.bin",
|
|
):
|
|
with patch("os.path.getsize", return_value=100000000):
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
"psutil": mock_psutil,
|
|
},
|
|
):
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_compute_backend",
|
|
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="Vulkan",
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_vulkan_devices",
|
|
return_value=vulkan_devices,
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"_prefer_discrete_vulkan_device",
|
|
return_value=1,
|
|
):
|
|
manager._init_whispercpp()
|
|
assert mock_pywhispercpp.Model.call_args.kwargs.get(
|
|
"context_params"
|
|
) == {"gpu_device": 1}
|
|
|
|
def test_gpu_device_explicit_index(self):
|
|
"""Test explicit GPU device index selection."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=0)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
mock_model = MagicMock()
|
|
mock_pywhispercpp.Model = MagicMock(return_value=mock_model)
|
|
mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model)
|
|
|
|
mock_psutil = MagicMock()
|
|
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
|
|
|
|
vulkan_devices = [
|
|
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
|
|
{"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"},
|
|
]
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value="/fake/model.bin",
|
|
):
|
|
with patch("os.path.getsize", return_value=100000000):
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
"psutil": mock_psutil,
|
|
},
|
|
):
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_compute_backend",
|
|
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="Vulkan",
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_vulkan_devices",
|
|
return_value=vulkan_devices,
|
|
):
|
|
manager._init_whispercpp()
|
|
assert mock_pywhispercpp.Model.call_args.kwargs.get(
|
|
"context_params"
|
|
) == {"gpu_device": 0}
|
|
|
|
def test_gpu_device_context_params_fallback_on_old_pywhispercpp(self):
|
|
"""Test graceful fallback when pywhispercpp rejects context_params."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
mock_model = MagicMock()
|
|
|
|
def _model_side_effect(*args, **kwargs):
|
|
if kwargs.get("context_params"):
|
|
raise TypeError("Model() got an unexpected keyword argument 'context_params'")
|
|
return mock_model
|
|
|
|
mock_pywhispercpp.Model.side_effect = _model_side_effect
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
},
|
|
):
|
|
result = manager._load_model_with_compatible_params("/fake/model.bin", {}, gpu_device=1)
|
|
assert result is mock_model
|
|
assert mock_pywhispercpp.Model.call_count == 2
|
|
assert mock_pywhispercpp.Model.call_args_list[0].kwargs.get("context_params") == {
|
|
"gpu_device": 1
|
|
}
|
|
assert "context_params" not in mock_pywhispercpp.Model.call_args_list[1].kwargs
|
|
|
|
def test_gpu_device_not_set_for_cpu_backend(self):
|
|
"""Test that GPU device selection is skipped on CPU backend."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
mock_model = MagicMock()
|
|
mock_pywhispercpp.Model = MagicMock(return_value=mock_model)
|
|
mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model)
|
|
|
|
mock_psutil = MagicMock()
|
|
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
|
|
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.get_model_path",
|
|
return_value="/fake/model.bin",
|
|
):
|
|
with patch("os.path.getsize", return_value=100000000):
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
"psutil": mock_psutil,
|
|
},
|
|
):
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_compute_backend",
|
|
return_value=(whispercpp_info.ComputeBackend.CPU, "CPU"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="CPU",
|
|
):
|
|
manager._init_whispercpp()
|
|
assert (
|
|
"context_params"
|
|
not in mock_pywhispercpp.Model.call_args.kwargs
|
|
)
|
|
|
|
|
|
class TestTranscription(unittest.TestCase):
|
|
"""Test transcription methods."""
|
|
|
|
def test_transcribe_with_whisper_empty_buffer(self):
|
|
"""Test Whisper transcription with empty buffer."""
|
|
manager = _make_manager(engine="whisper")
|
|
manager.model = MagicMock()
|
|
|
|
result = manager._transcribe_with_whisper([])
|
|
assert result == ""
|
|
|
|
def test_transcribe_with_whisper_none_model(self):
|
|
"""Test Whisper transcription when model is None."""
|
|
manager = _make_manager(engine="whisper")
|
|
manager.model = None
|
|
|
|
audio_buffer = [b"\x00\x00\x00\x00"]
|
|
result = manager._transcribe_with_whisper(audio_buffer)
|
|
assert result == ""
|
|
|
|
def test_transcribe_with_whisper_success(self):
|
|
"""Test successful Whisper transcription."""
|
|
manager = _make_manager(engine="whisper")
|
|
|
|
# Create mock that behaves like a torch device
|
|
mock_device = MagicMock()
|
|
mock_device.__ne__ = MagicMock(return_value=True) # device != torch.device("cpu")
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.device = mock_device
|
|
mock_model.transcribe.return_value = {"text": "hello world"}
|
|
manager.model = mock_model
|
|
manager.language = "en-us"
|
|
|
|
audio_buffer = [b"\x00\x00" for _ in range(16000)] # 1 second of audio
|
|
|
|
mock_np = MagicMock()
|
|
mock_audio_data = MagicMock()
|
|
mock_audio_float = MagicMock()
|
|
|
|
mock_np.frombuffer.return_value = mock_audio_data
|
|
mock_audio_data.astype.return_value = mock_audio_float
|
|
|
|
mock_torch = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"numpy": mock_np, "torch": mock_torch}):
|
|
result = manager._transcribe_with_whisper(audio_buffer)
|
|
assert result == "hello world"
|
|
|
|
def test_transcribe_with_whispercpp_empty_buffer(self):
|
|
"""Test whisper.cpp transcription with empty buffer."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager.model = MagicMock()
|
|
|
|
result = manager._transcribe_with_whispercpp([])
|
|
assert result == ""
|
|
|
|
def test_transcribe_with_whispercpp_none_model(self):
|
|
"""Test whisper.cpp transcription when model is None."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
manager.model = None
|
|
|
|
audio_buffer = [b"\x00\x00\x00\x00"]
|
|
result = manager._transcribe_with_whispercpp(audio_buffer)
|
|
assert result == ""
|
|
|
|
def test_transcribe_with_whispercpp_success(self):
|
|
"""Test successful whisper.cpp transcription."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
|
|
# Mock segment with text attribute
|
|
mock_segment = MagicMock()
|
|
mock_segment.text = "test result"
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.transcribe.return_value = [mock_segment]
|
|
manager.model = mock_model
|
|
manager.language = "en-us"
|
|
|
|
audio_buffer = [b"\x00\x00\x00\x00" * 16000]
|
|
|
|
mock_np = MagicMock()
|
|
mock_np.frombuffer.return_value = MagicMock()
|
|
mock_np.frombuffer.return_value.astype.return_value = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"numpy": mock_np, "np": mock_np}):
|
|
result = manager._transcribe_with_whispercpp(audio_buffer)
|
|
assert result == "test result"
|
|
|
|
|
|
class TestStartStopRecognition(unittest.TestCase):
|
|
"""Test start_recognition and stop_recognition flows."""
|
|
|
|
def test_start_recognition_invalid_state(self):
|
|
"""Test starting recognition when not IDLE."""
|
|
manager = _make_manager()
|
|
manager.state = RecognitionState.LISTENING
|
|
|
|
with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"):
|
|
manager.start_recognition()
|
|
# Should return early without starting threads
|
|
|
|
def test_start_recognition_model_not_ready(self):
|
|
"""Test starting recognition when model not ready."""
|
|
manager = _make_manager()
|
|
manager.state = RecognitionState.IDLE
|
|
manager._model_initialized = False
|
|
|
|
with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"):
|
|
with patch("vocalinux.speech_recognition.recognition_manager._show_notification"):
|
|
manager.start_recognition()
|
|
assert manager.state == RecognitionState.IDLE # Should not change
|
|
|
|
def test_start_recognition_success(self):
|
|
"""Test successful recognition start."""
|
|
manager = _make_manager()
|
|
manager.state = RecognitionState.IDLE
|
|
manager._model_initialized = True
|
|
manager.model = MagicMock()
|
|
|
|
with patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"):
|
|
with patch.object(manager, "_record_audio"):
|
|
with patch.object(manager, "_perform_recognition"):
|
|
manager.start_recognition()
|
|
assert manager.state == RecognitionState.LISTENING
|
|
assert manager.should_record is True
|
|
|
|
def test_stop_recognition_when_idle(self):
|
|
"""Test stopping recognition when already idle."""
|
|
manager = _make_manager()
|
|
manager.state = RecognitionState.IDLE
|
|
|
|
manager.stop_recognition()
|
|
# Should return early
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
def test_stop_recognition_with_threads(self):
|
|
"""Test stopping recognition with active threads."""
|
|
manager = _make_manager()
|
|
manager.state = RecognitionState.LISTENING
|
|
manager.should_record = True
|
|
manager.audio_buffer = [b"\x00\x00" for _ in range(20)]
|
|
manager._recording_segment_has_speech = True
|
|
|
|
# Create dummy threads
|
|
manager.audio_thread = MagicMock()
|
|
manager.audio_thread.is_alive.return_value = False
|
|
manager.recognition_thread = MagicMock()
|
|
manager.recognition_thread.is_alive.return_value = False
|
|
|
|
with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"):
|
|
with patch.object(manager, "_signal_recognition_stop"):
|
|
with patch.object(manager, "_enqueue_audio_segment") as enqueue_mock:
|
|
manager.stop_recognition()
|
|
assert manager.should_record is False
|
|
assert len(enqueue_mock.call_args.args[0]) == 17
|
|
assert manager._recording_segment_has_speech is False
|
|
|
|
def test_stop_recognition_drops_final_buffer_without_speech(self):
|
|
"""Final silence-only buffers should not be queued for transcription."""
|
|
manager = _make_manager()
|
|
manager.state = RecognitionState.LISTENING
|
|
manager.should_record = True
|
|
manager.audio_buffer = [b"\x00\x00" for _ in range(20)]
|
|
manager._recording_segment_has_speech = False
|
|
|
|
manager.audio_thread = MagicMock()
|
|
manager.audio_thread.is_alive.return_value = False
|
|
manager.recognition_thread = MagicMock()
|
|
manager.recognition_thread.is_alive.return_value = False
|
|
|
|
with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"):
|
|
with patch.object(manager, "_signal_recognition_stop"):
|
|
with patch.object(manager, "_enqueue_audio_segment") as enqueue_mock:
|
|
manager.stop_recognition()
|
|
enqueue_mock.assert_not_called()
|
|
assert manager.audio_buffer == []
|
|
|
|
|
|
class TestDownloads(unittest.TestCase):
|
|
"""Test model download functions."""
|
|
|
|
def test_download_whispercpp_model_success(self):
|
|
"""Test successful whisper.cpp model download - skipped (requires requests)."""
|
|
# This test requires requests library which may not be available
|
|
# The actual download logic is tested through integration tests
|
|
pass
|
|
|
|
def test_download_whispercpp_model_cancelled(self):
|
|
"""Test cancelled whisper.cpp model download - skipped (requires requests)."""
|
|
# This test requires requests library which may not be available
|
|
# The actual download logic is tested through integration tests
|
|
pass
|
|
|
|
def test_download_vosk_model_success(self):
|
|
"""Test successful VOSK model download - skipped (requires requests)."""
|
|
# This test requires requests library which may not be available
|
|
# The actual download logic is tested through integration tests
|
|
pass
|
|
|
|
def test_download_vosk_model_bad_zip(self):
|
|
"""Test VOSK download with corrupted zip file - skipped (requires requests)."""
|
|
# This test requires requests library which may not be available
|
|
# The actual download logic is tested through integration tests
|
|
pass
|
|
|
|
|
|
class TestReconfiguration(unittest.TestCase):
|
|
"""Test reconfiguration and model switching."""
|
|
|
|
def test_reconfigure_engine(self):
|
|
"""Test reconfiguring to a different engine."""
|
|
manager = _make_manager(engine="vosk")
|
|
manager.engine = "vosk"
|
|
|
|
with patch.object(manager, "_init_whisper"):
|
|
with patch.object(manager, "stop_recognition"):
|
|
manager.reconfigure(engine="whisper")
|
|
assert manager.engine == "whisper"
|
|
|
|
|
|
class TestEnqueueAudioSegment(unittest.TestCase):
|
|
"""Direct tests for _enqueue_audio_segment (not mocked)."""
|
|
|
|
def test_enqueue_empty_buffer_returns_early(self):
|
|
"""Empty buffer should return without enqueuing anything."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager._enqueue_audio_segment([])
|
|
assert manager._segment_queue.empty()
|
|
|
|
def test_enqueue_puts_segment(self):
|
|
"""Non-empty buffer should be copied and enqueued."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
buf = [b"\x00\x01", b"\x02\x03"]
|
|
manager._enqueue_audio_segment(buf)
|
|
assert not manager._segment_queue.empty()
|
|
assert manager._segment_queue.get_nowait() == buf
|
|
|
|
def test_enqueue_drops_oldest_when_queue_full(self):
|
|
"""When queue is full, oldest item is dropped and new one inserted."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue(maxsize=1)
|
|
manager._segment_queue.put_nowait([b"old"])
|
|
manager._enqueue_audio_segment([b"new"])
|
|
assert manager._segment_queue.get_nowait() == [b"new"]
|
|
|
|
|
|
class TestPerformRecognition(unittest.TestCase):
|
|
"""Direct tests for _perform_recognition (not mocked)."""
|
|
|
|
def _run_recognition(self, manager, pre_hook=None):
|
|
"""Start _perform_recognition in a thread and return the thread."""
|
|
t = threading.Thread(target=manager._perform_recognition)
|
|
t.start()
|
|
if pre_hook:
|
|
pre_hook(manager)
|
|
return t
|
|
|
|
def test_processes_segment_and_exits_on_none_signal(self):
|
|
"""Feed a segment, then a None signal; thread processes and exits."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = True
|
|
|
|
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
|
with patch.object(manager, "_update_state"):
|
|
t = self._run_recognition(manager)
|
|
manager._segment_queue.put([b"\x00\x01"])
|
|
manager.should_record = False
|
|
manager._segment_queue.put(None)
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
assert mock_proc.called
|
|
|
|
def test_drains_remaining_items_on_none_signal(self):
|
|
"""None signal drains any remaining queued segments before exit."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = True
|
|
|
|
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
|
with patch.object(manager, "_update_state"):
|
|
t = self._run_recognition(manager)
|
|
manager._segment_queue.put([b"first"])
|
|
manager._segment_queue.put([b"second"])
|
|
manager.should_record = False
|
|
manager._segment_queue.put(None)
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
assert mock_proc.call_count == 2
|
|
|
|
def test_exits_when_idle_and_queue_empty(self):
|
|
"""When not recording and queue empty, thread exits after brief wait."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = False
|
|
|
|
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
|
with patch.object(manager, "_update_state"):
|
|
t = self._run_recognition(manager)
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
assert not mock_proc.called
|
|
|
|
def test_queue_timeout_while_recording(self):
|
|
"""While recording, an empty-queue timeout causes the loop to continue."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = True
|
|
|
|
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
|
with patch.object(manager, "_update_state"):
|
|
t = self._run_recognition(manager)
|
|
# Allow at least one 0.1 s timeout to fire
|
|
time.sleep(0.25)
|
|
manager.should_record = False
|
|
manager._segment_queue.put(None)
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|