The fork takes its own name everywhere a user can see it: binaries (voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus namespace (com.voculinux.Voculinux, no alias for the old name), config/data/cache dirs, single-instance lock, IBus engine identity, tray icons, resource prefixes, VOCULINUX_* environment names, and the update-checker user agent. The internal Python module stays vocalinux and the distribution metadata keeps the upstream URLs as provenance. On first run a previous Vocalinux install's config.json, custom dictionary, and portal restore token are copied once into the new locations (new config always wins, no merge, no write-back); models re-resolve through the pinned-checksum download path. An offline naming-sweep test proves no old-name strings remain on user-visible surfaces outside the allowlist. Refs #18
2652 lines
114 KiB
Python
2652 lines
114 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 typing import Any, Optional
|
|
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 (
|
|
_STICKY_LOCK_MIN_MEAN_SQUARE,
|
|
SpeechRecognitionManager,
|
|
_downmix_to_mono,
|
|
_filter_non_speech,
|
|
_get_supported_channels,
|
|
_get_supported_sample_rate,
|
|
_is_bluetooth_device,
|
|
_is_virtual_device,
|
|
_open_capture_stream,
|
|
_resolve_valid_input_device,
|
|
_safe_close_stream,
|
|
get_audio_input_devices,
|
|
)
|
|
from vocalinux.speech_recognition.recognition_manager import (
|
|
test_audio_input as _run_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_virtual_device_filters_pipewire_pseudo_sources(self):
|
|
"""PipeWire pseudo sources from issue #624 should not be opened by index."""
|
|
unsafe_names = [
|
|
"default",
|
|
"DeepFilterNet Source",
|
|
"paplay",
|
|
"pipewire",
|
|
"PipeWire filter-chain source",
|
|
"Monitor of Built-in Audio Analog Stereo",
|
|
"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
|
|
"Null Sink",
|
|
"dummy",
|
|
"speech-dispatcher-dummy",
|
|
]
|
|
|
|
for name in unsafe_names:
|
|
assert _is_virtual_device(name), name
|
|
|
|
assert not _is_virtual_device("USB Microphone")
|
|
assert not _is_virtual_device("Built-in Audio Analog Stereo")
|
|
assert not _is_virtual_device("Built-in Mic (PipeWire)")
|
|
assert not _is_virtual_device("")
|
|
|
|
def test_get_audio_input_devices_filters_unsafe_virtual_sources(self):
|
|
"""Device enumeration should hide unsafe virtual sources but keep real mics."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 4
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 2}
|
|
devices = [
|
|
{"index": 0, "name": "DeepFilterNet Source", "maxInputChannels": 2},
|
|
{"index": 1, "name": "Monitor of Built-in Audio Analog Stereo", "maxInputChannels": 2},
|
|
{"index": 2, "name": "USB Microphone", "maxInputChannels": 1},
|
|
{"index": 3, "name": "default", "maxInputChannels": 32},
|
|
]
|
|
mock_audio.get_device_info_by_index.side_effect = lambda i: devices[i]
|
|
mock_pyaudio = MagicMock(PyAudio=MagicMock(return_value=mock_audio))
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
assert get_audio_input_devices() == [(2, "USB Microphone", True)]
|
|
|
|
def test_resolve_valid_input_device_skips_stale_virtual_index(self):
|
|
"""A saved virtual source index should fall back to a physical microphone."""
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 3
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 1}
|
|
devices = [
|
|
{"index": 0, "name": "DeepFilterNet Source", "maxInputChannels": 2},
|
|
{"index": 1, "name": "default", "maxInputChannels": 32},
|
|
{"index": 2, "name": "USB Microphone", "maxInputChannels": 1},
|
|
]
|
|
mock_audio.get_device_info_by_index.side_effect = lambda i: devices[i]
|
|
|
|
assert _resolve_valid_input_device(mock_audio, preferred_index=0) == 2
|
|
|
|
def test_system_default_selection_uses_no_explicit_device_index(self):
|
|
"""System Default should still open through PortAudio's default device path."""
|
|
manager = _make_manager(
|
|
engine="whisper_cpp", audio_device_index=None, audio_device_name=None
|
|
)
|
|
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.get_read_available.return_value = 1024
|
|
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": "default",
|
|
"maxInputChannels": 32,
|
|
}
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 0, "name": "default"}
|
|
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
|
|
patch(
|
|
"vocalinux.audio.capture._open_capture_stream",
|
|
return_value=(1, 16000, mock_stream),
|
|
) as mock_open,
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.play_error_sound",
|
|
),
|
|
):
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
del sys.modules["numpy"]
|
|
manager._record_audio()
|
|
|
|
mock_open.assert_called_once_with(mock_audio, None)
|
|
|
|
def test_record_audio_falls_back_to_system_default_when_no_safe_device(self):
|
|
"""Stale/unsafe saved devices should reopen via PortAudio system default."""
|
|
manager = _make_manager(
|
|
engine="whisper_cpp",
|
|
audio_device_index=0,
|
|
audio_device_name="DeepFilterNet Source",
|
|
)
|
|
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.get_read_available.return_value = 1024
|
|
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": "DeepFilterNet Source",
|
|
"maxInputChannels": 2,
|
|
}
|
|
mock_audio.get_default_input_device_info.return_value = {
|
|
"index": 0,
|
|
"name": "default",
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
mock_pyaudio.PyAudio.return_value = mock_audio
|
|
|
|
with (
|
|
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
|
|
patch(
|
|
"vocalinux.audio.capture._resolve_device_by_name",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"vocalinux.audio.capture._resolve_valid_input_device",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"vocalinux.audio.capture._open_capture_stream",
|
|
return_value=(1, 16000, mock_stream),
|
|
) as mock_open,
|
|
patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"),
|
|
):
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
del sys.modules["numpy"]
|
|
manager._record_audio()
|
|
|
|
mock_open.assert_called_once_with(mock_audio, None)
|
|
mock_audio.get_default_input_device_info.assert_called()
|
|
|
|
def test_test_audio_input_system_default_fallback_open_omits_device_index(self):
|
|
"""Fallback mic-test open for System Default must omit input_device_index."""
|
|
mock_stream = MagicMock()
|
|
mock_stream.get_read_available.return_value = 1024
|
|
mock_stream.read.return_value = b"\x00\x01" * 1024
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_default_input_device_info.return_value = {
|
|
"index": 7,
|
|
"name": "default",
|
|
"defaultSampleRate": 48000,
|
|
"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.audio.capture._open_capture_stream",
|
|
return_value=(1, 48000, None),
|
|
):
|
|
result = _run_test_audio_input(device_index=None, duration=0.1)
|
|
|
|
assert result["success"] is True
|
|
kwargs = mock_audio.open.call_args.kwargs
|
|
assert "input_device_index" not in kwargs
|
|
|
|
def test_settings_persist_raw_audio_device_name(self):
|
|
"""Settings should persist raw PortAudio names, not UI-only suffixes."""
|
|
from vocalinux.ui.settings_dialog import (
|
|
_raw_audio_device_name,
|
|
_resolve_audio_device_selection,
|
|
)
|
|
|
|
assert _raw_audio_device_name("USB Microphone (default)") == "USB Microphone"
|
|
assert _raw_audio_device_name("USB Microphone") == "USB Microphone"
|
|
assert _raw_audio_device_name(None) is None
|
|
|
|
devices = [(3, "USB Microphone", True), (5, "Webcam Mic", False)]
|
|
# Legacy configs may still store the UI-only "(default)" suffix.
|
|
assert _resolve_audio_device_selection(devices, 3, "USB Microphone (default)") == 3
|
|
assert _resolve_audio_device_selection(devices, 99, "Webcam Mic") == 5
|
|
# Filtered-out / missing devices resolve to System Default.
|
|
assert _resolve_audio_device_selection(devices, 1, "DeepFilterNet") is None
|
|
assert _resolve_audio_device_selection(devices, 1, None) is None
|
|
|
|
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_stereo_device_prefers_native_channels(self):
|
|
"""Stereo devices must be opened 2ch-first, not converted mono (#666).
|
|
|
|
Intel SOF digital mics report maxInputChannels=2. Opening them as
|
|
1ch can succeed then abort in PortAudio's callback with
|
|
``free(): corrupted unsorted chunks``.
|
|
"""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "Raptor Lake-P/U/H cAVS Digital Microphone",
|
|
"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, 18)
|
|
|
|
assert channels == 2
|
|
assert rate == 48000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_count == 1
|
|
assert mock_audio.open.call_args.kwargs.get("channels") == 2
|
|
|
|
def test_open_capture_stream_stereo_device_falls_back_to_mono(self):
|
|
"""If native stereo open fails, still try mono on a 2ch-reporting device."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
if kwargs.get("channels") == 2:
|
|
raise IOError("[Errno -9998] Invalid number of channels")
|
|
return mock_stream
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "USB Microphone",
|
|
"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 == 1
|
|
assert rate == 48000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_args.kwargs.get("channels") == 1
|
|
assert any(call.kwargs.get("channels") == 2 for call in mock_audio.open.call_args_list)
|
|
|
|
def test_open_capture_stream_hda_4ch_prefers_native_channels(self):
|
|
"""HDA mics reporting 4 capture channels must open 4ch-first (#813).
|
|
|
|
Built-in analog HDA devices often expose 4ch (front/rear). Opening
|
|
at 2ch can succeed then abort in PortAudio CleanUpStream with
|
|
``free(): corrupted unsorted chunks``.
|
|
"""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "HDA Intel PCH Analog",
|
|
"defaultSampleRate": 48000,
|
|
"maxInputChannels": 4,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 0)
|
|
|
|
assert channels == 4
|
|
assert rate == 48000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_count == 1
|
|
assert mock_audio.open.call_args.kwargs.get("channels") == 4
|
|
|
|
def test_open_capture_stream_pulse_32ch_opens_stereo_never_native(self):
|
|
"""Pulse devices reporting 32 channels must try 2ch first, never 32."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "pulse",
|
|
"defaultSampleRate": 48000,
|
|
"maxInputChannels": 32,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 0)
|
|
|
|
assert channels == 2
|
|
assert rate == 48000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_args.kwargs.get("channels") == 2
|
|
assert all(call.kwargs.get("channels") != 32 for call in mock_audio.open.call_args_list)
|
|
|
|
def test_open_capture_stream_hda_4ch_falls_back_to_stereo(self):
|
|
"""If native 4ch open fails, still try 2ch on a 4ch-reporting device."""
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
if kwargs.get("channels") == 4:
|
|
raise IOError("[Errno -9998] Invalid number of channels")
|
|
return mock_stream
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "HDA Intel PCH Analog",
|
|
"defaultSampleRate": 48000,
|
|
"maxInputChannels": 4,
|
|
}
|
|
mock_pyaudio = MagicMock(paInt16=8)
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
|
|
channels, rate, stream = _open_capture_stream(mock_audio, 0)
|
|
|
|
assert channels == 2
|
|
assert rate == 48000
|
|
assert stream is mock_stream
|
|
assert mock_audio.open.call_args.kwargs.get("channels") == 2
|
|
assert any(call.kwargs.get("channels") == 4 for call in mock_audio.open.call_args_list)
|
|
|
|
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.get_read_available.return_value = 1024
|
|
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_system_default_keeps_null_device_index(self):
|
|
"""System Default must open PortAudio without an explicit device index."""
|
|
from vocalinux.speech_recognition.recognition_manager import test_audio_input
|
|
|
|
mock_stream = MagicMock()
|
|
mock_stream.get_read_available.return_value = 1024
|
|
mock_stream.read.return_value = b"\x00\x01" * 1024
|
|
mock_audio = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
mock_audio.get_default_input_device_info.return_value = {
|
|
"index": 7,
|
|
"name": "default",
|
|
"defaultSampleRate": 48000,
|
|
"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.audio.capture._open_capture_stream",
|
|
return_value=(1, 48000, mock_stream),
|
|
) as mock_open:
|
|
result = test_audio_input(device_index=None, duration=0.1)
|
|
|
|
assert result["success"] is True
|
|
mock_open.assert_called_once_with(mock_audio, None)
|
|
# Display metadata can still report the host default index/name.
|
|
assert result["device_index"] == 7
|
|
assert result["device_name"] == "default"
|
|
|
|
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.get_read_available.return_value = 1024
|
|
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.audio.capture._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
):
|
|
result = _run_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.audio.capture._open_capture_stream",
|
|
return_value=(1, 16000, None),
|
|
):
|
|
result = _run_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 TestDownmixToMono(unittest.TestCase):
|
|
"""Energy-aware loudest-channel downmix policy for HDA capture (#813 / PR #829)."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# Other test modules may have left sys.modules["numpy"] as MagicMock.
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
real = getattr(sys, "_voculinux_real_numpy", None)
|
|
if real is not None:
|
|
sys.modules["numpy"] = real
|
|
else:
|
|
del sys.modules["numpy"]
|
|
import numpy as np
|
|
|
|
cls.np = np
|
|
|
|
def test_mono_passthrough_unchanged(self):
|
|
np = self.np
|
|
audio = np.array([100, 200, 300], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 1)
|
|
np.testing.assert_array_equal(out, audio)
|
|
assert out is audio
|
|
assert sticky is None
|
|
|
|
def test_stereo_mean_of_both_channels(self):
|
|
np = self.np
|
|
audio = np.array([100, 200], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 2)
|
|
np.testing.assert_array_equal(out, np.array([150], dtype=np.int16))
|
|
assert sticky is None
|
|
|
|
def test_quad_loudest_channel_does_not_halve_speech_level(self):
|
|
"""4ch (speech, speech, silence, silence) keeps the loudest channel at full level."""
|
|
np = self.np
|
|
audio = np.array([1000, 1000, 0, 0], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 4)
|
|
np.testing.assert_array_equal(out, np.array([1000], dtype=np.int16))
|
|
# Averaging all 4 channels would have produced 500.
|
|
assert out[0] != 500
|
|
assert sticky == 0
|
|
|
|
def test_quad_recovers_speech_on_channel_2(self):
|
|
"""4ch (silence, silence, speech, silence) selects the loud rear channel."""
|
|
np = self.np
|
|
audio = np.array([0, 0, 1000, 0], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 4)
|
|
np.testing.assert_array_equal(out, np.array([1000], dtype=np.int16))
|
|
assert sticky == 2
|
|
|
|
def test_quad_recovers_speech_on_channel_3(self):
|
|
"""4ch (silence, silence, silence, speech) selects the loud rear channel."""
|
|
np = self.np
|
|
audio = np.array([0, 0, 0, 1000], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 4)
|
|
np.testing.assert_array_equal(out, np.array([1000], dtype=np.int16))
|
|
assert sticky == 3
|
|
|
|
def test_quad_leftover_truncation(self):
|
|
"""Incomplete trailing frame is dropped using full N-channel width, then loudest channel."""
|
|
np = self.np
|
|
audio = np.array([1000, 1000, 0, 0, 2000, 2000, 0, 0, 99], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 4)
|
|
np.testing.assert_array_equal(out, np.array([1000, 2000], dtype=np.int16))
|
|
assert sticky == 0
|
|
|
|
def test_quad_leftover_truncation_then_loudest_rear_channel(self):
|
|
"""Truncate leftover at full N-channel width, then pick speech on ch2 (not first pair)."""
|
|
np = self.np
|
|
audio = np.array([0, 0, 1000, 0, 0, 0, 2000, 0, 99], dtype=np.int16)
|
|
out, sticky = _downmix_to_mono(audio, 4)
|
|
np.testing.assert_array_equal(out, np.array([1000, 2000], dtype=np.int16))
|
|
assert sticky == 2
|
|
|
|
def test_quad_sticky_channel_ignores_later_noise_burst(self):
|
|
"""First buffer speech on ch2 sticks; later louder noise on ch0 still returns ch2."""
|
|
np = self.np
|
|
first = np.array([0, 0, 1000, 0], dtype=np.int16)
|
|
out1, sticky = _downmix_to_mono(first, 4)
|
|
np.testing.assert_array_equal(out1, np.array([1000], dtype=np.int16))
|
|
assert sticky == 2
|
|
# Noise burst on ch0 is louder than sticky mic speech on ch2.
|
|
second = np.array([5000, 0, 800, 0], dtype=np.int16)
|
|
out2, sticky2 = _downmix_to_mono(second, 4, sticky)
|
|
np.testing.assert_array_equal(out2, np.array([800], dtype=np.int16))
|
|
assert sticky2 == 2
|
|
|
|
def test_quad_front_pair_full_level_with_sticky(self):
|
|
"""Front-pair speech still comes through at full level once sticky is set."""
|
|
np = self.np
|
|
first = np.array([1200, 1100, 0, 0], dtype=np.int16)
|
|
out1, sticky = _downmix_to_mono(first, 4)
|
|
np.testing.assert_array_equal(out1, np.array([1200], dtype=np.int16))
|
|
assert sticky == 0
|
|
second = np.array([900, 850, 0, 0], dtype=np.int16)
|
|
out2, sticky2 = _downmix_to_mono(second, 4, sticky)
|
|
np.testing.assert_array_equal(out2, np.array([900], dtype=np.int16))
|
|
assert sticky2 == 0
|
|
|
|
def test_quad_silent_first_buffer_does_not_lock_sticky(self):
|
|
"""Idle/ambient first buffer must not permanently pin ch0 before speech."""
|
|
np = self.np
|
|
# ch0 has tiny ambient (mean-square << lock floor); others silent.
|
|
ambient = 20 # MS = 400 << _STICKY_LOCK_MIN_MEAN_SQUARE (10000)
|
|
assert ambient * ambient < _STICKY_LOCK_MIN_MEAN_SQUARE
|
|
first = np.array([ambient, 0, 0, 0], dtype=np.int16)
|
|
out1, sticky = _downmix_to_mono(first, 4)
|
|
np.testing.assert_array_equal(out1, np.array([ambient], dtype=np.int16))
|
|
assert sticky is None
|
|
# Second buffer: real speech on ch2 — lock there, not ch0.
|
|
second = np.array([0, 0, 1000, 0], dtype=np.int16)
|
|
out2, sticky2 = _downmix_to_mono(second, 4, sticky)
|
|
np.testing.assert_array_equal(out2, np.array([1000], dtype=np.int16))
|
|
assert sticky2 == 2
|
|
|
|
def test_quad_near_silence_noise_then_speech_locks_speech_channel(self):
|
|
"""Noise-floor ch0 then speech on ch2: return speech and pin ch2."""
|
|
np = self.np
|
|
# Multi-frame idle: faint noise on ch0 only.
|
|
idle = np.array(
|
|
[30, 0, 0, 0, 40, 0, 0, 0, 25, 0, 0, 0],
|
|
dtype=np.int16,
|
|
)
|
|
out1, sticky = _downmix_to_mono(idle, 4)
|
|
assert sticky is None
|
|
assert out1.tolist() == [30, 40, 25]
|
|
speech = np.array([10, 0, 2000, 0, 5, 0, 1800, 0], dtype=np.int16)
|
|
out2, sticky2 = _downmix_to_mono(speech, 4, sticky)
|
|
np.testing.assert_array_equal(out2, np.array([2000, 1800], dtype=np.int16))
|
|
assert sticky2 == 2
|
|
# Once locked, later louder noise on another channel stays ignored.
|
|
burst = np.array([8000, 0, 900, 0], dtype=np.int16)
|
|
out3, sticky3 = _downmix_to_mono(burst, 4, sticky2)
|
|
np.testing.assert_array_equal(out3, np.array([900], dtype=np.int16))
|
|
assert sticky3 == 2
|
|
|
|
|
|
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.get_read_available.return_value = 1024
|
|
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.audio.capture._resolve_device_by_name",
|
|
return_value=0,
|
|
),
|
|
patch(
|
|
"vocalinux.audio.capture._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/voculinux"],
|
|
):
|
|
with patch.object(
|
|
manager, "_get_vosk_model_path", return_value="/usr/share/voculinux/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 setUp(self):
|
|
# These cover device/backend selection, not integrity. _init_whispercpp
|
|
# now hashes an existing model before handing it to ctypes, and the model
|
|
# here is a mocked path with no bytes behind it. Verification itself is
|
|
# covered by tests/test_model_checksums.py.
|
|
patcher = patch.object(
|
|
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
|
|
)
|
|
self.addCleanup(patcher.stop)
|
|
patcher.start()
|
|
|
|
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) -> None:
|
|
"""Test whisper.cpp GPU fallback to CPU."""
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
for thread_override, expected_threads in ((None, (2, 6)), (3, (3, 3))):
|
|
with self.subTest(thread_override=thread_override):
|
|
manager = _make_manager(
|
|
engine="whisper_cpp",
|
|
whispercpp_n_threads=thread_override,
|
|
)
|
|
manager.model_size = "tiny"
|
|
manager.whispercpp_gpu_device = 0
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
model_class = MagicMock(
|
|
side_effect=[
|
|
RuntimeError("16-bit storage not supported"),
|
|
MagicMock(),
|
|
]
|
|
)
|
|
mock_pywhispercpp.Model = model_class
|
|
mock_pywhispercpp.model.Model = model_class
|
|
|
|
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.object(
|
|
manager,
|
|
"_whispercpp_model_is_verified",
|
|
return_value=True,
|
|
):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
"psutil": mock_psutil,
|
|
},
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_compute_backend",
|
|
return_value=(MagicMock(), "test"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_cpu_info",
|
|
return_value="test CPU",
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_vulkan_devices",
|
|
return_value=[{"index": 0, "name": "test GPU"}],
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="Vulkan",
|
|
):
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="vulkan",
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count",
|
|
return_value=8,
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager._show_notification"
|
|
):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{},
|
|
clear=True,
|
|
):
|
|
manager._init_whispercpp()
|
|
|
|
self.assertTrue(manager._model_initialized)
|
|
self.assertEqual(model_class.call_count, 2)
|
|
self.assertEqual(
|
|
tuple(call.kwargs["n_threads"] for call in model_class.call_args_list),
|
|
expected_threads,
|
|
)
|
|
self.assertEqual(manager._whispercpp_effective_threads, expected_threads[-1])
|
|
|
|
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 setUp(self):
|
|
# These cover device/backend selection, not integrity. _init_whispercpp
|
|
# now hashes an existing model before handing it to ctypes, and the model
|
|
# here is a mocked path with no bytes behind it. Verification itself is
|
|
# covered by tests/test_model_checksums.py.
|
|
patcher = patch.object(
|
|
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
|
|
)
|
|
self.addCleanup(patcher.stop)
|
|
patcher.start()
|
|
|
|
class ContextParamsModel:
|
|
calls = []
|
|
|
|
def __init__(self, model_path, context_params=None, **kwargs):
|
|
call_kwargs = dict(kwargs)
|
|
if context_params is not None:
|
|
call_kwargs["context_params"] = context_params
|
|
self.__class__.calls.append((model_path, call_kwargs))
|
|
|
|
class OldSignatureModel:
|
|
calls = []
|
|
|
|
def __init__(self, model_path, **kwargs):
|
|
self.__class__.calls.append((model_path, dict(kwargs)))
|
|
|
|
class AttributeErrorContextParamsModel:
|
|
calls = []
|
|
|
|
def __init__(self, model_path, context_params=None, **kwargs):
|
|
call_kwargs = dict(kwargs)
|
|
if context_params is not None:
|
|
call_kwargs["context_params"] = context_params
|
|
self.__class__.calls.append((model_path, call_kwargs))
|
|
if context_params is not None:
|
|
raise AttributeError(
|
|
"'whisper_full_params' object has no attribute 'context_params'"
|
|
)
|
|
|
|
def _init_with_backends(
|
|
self,
|
|
manager,
|
|
*,
|
|
host_backend,
|
|
host_info,
|
|
actual_backend,
|
|
vulkan_devices=None,
|
|
prefer_index=None,
|
|
):
|
|
mock_pywhispercpp = MagicMock()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
mock_pywhispercpp.model.Model = self.ContextParamsModel
|
|
mock_psutil = MagicMock()
|
|
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
|
|
vulkan_devices = vulkan_devices or []
|
|
|
|
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=(host_backend, host_info),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
side_effect=lambda backend: backend,
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_cpu_info",
|
|
return_value="Test CPU",
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"detect_vulkan_devices",
|
|
return_value=vulkan_devices,
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"_prefer_discrete_vulkan_device",
|
|
return_value=prefer_index,
|
|
):
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value=actual_backend,
|
|
):
|
|
manager._init_whispercpp()
|
|
return self.ContextParamsModel.calls
|
|
|
|
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()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
mock_pywhispercpp.model.Model = self.ContextParamsModel
|
|
|
|
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,
|
|
):
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="vulkan",
|
|
):
|
|
manager._init_whispercpp()
|
|
assert self.ContextParamsModel.calls[-1][1].get(
|
|
"context_params"
|
|
) == {"gpu_device": 1}
|
|
|
|
def test_gpu_device_auto_select_cuda_backend_uses_device_zero(self):
|
|
"""CUDA pywhispercpp must use CUDA ordinals, not Vulkan GPU indices."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
mock_pywhispercpp.model.Model = self.ContextParamsModel
|
|
|
|
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 4070", "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,
|
|
):
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="cuda",
|
|
):
|
|
manager._init_whispercpp()
|
|
assert self.ContextParamsModel.calls[-1][1].get(
|
|
"context_params"
|
|
) == {"gpu_device": 0}
|
|
|
|
def test_gpu_device_cuda_backend_explicit_device_zero(self):
|
|
"""CUDA backend with Vulkan index 0 uses CUDA device 0 without remapping log."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=0)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
mock_pywhispercpp.model.Model = self.ContextParamsModel
|
|
|
|
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.VULKAN, "Vulkan GPU"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="Vulkan",
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"_prefer_discrete_vulkan_device",
|
|
) as prefer_mock:
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="cuda",
|
|
):
|
|
manager._init_whispercpp()
|
|
prefer_mock.assert_not_called()
|
|
assert self.ContextParamsModel.calls[-1][1].get(
|
|
"context_params"
|
|
) == {"gpu_device": 0}
|
|
|
|
def test_gpu_device_cuda_backend_remaps_explicit_vulkan_index(self):
|
|
"""CUDA backend remaps explicit Vulkan dGPU index to CUDA device 0."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
mock_pywhispercpp.model.Model = self.ContextParamsModel
|
|
|
|
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.VULKAN, "Vulkan GPU"),
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"get_backend_display_name",
|
|
return_value="Vulkan",
|
|
):
|
|
with patch.object(
|
|
whispercpp_info,
|
|
"_prefer_discrete_vulkan_device",
|
|
) as prefer_mock:
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="cuda",
|
|
):
|
|
manager._init_whispercpp()
|
|
prefer_mock.assert_not_called()
|
|
assert self.ContextParamsModel.calls[-1][1].get(
|
|
"context_params"
|
|
) == {"gpu_device": 0}
|
|
|
|
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()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
mock_pywhispercpp.model.Model = self.ContextParamsModel
|
|
|
|
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(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="vulkan",
|
|
):
|
|
manager._init_whispercpp()
|
|
assert self.ContextParamsModel.calls[-1][1].get(
|
|
"context_params"
|
|
) == {"gpu_device": 0}
|
|
|
|
def test_gpu_device_skips_context_params_for_old_pywhispercpp_signature(self):
|
|
"""Old pywhispercpp signatures must not receive context_params."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.OldSignatureModel.calls = []
|
|
mock_pywhispercpp.Model = self.OldSignatureModel
|
|
|
|
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 isinstance(result, self.OldSignatureModel)
|
|
assert len(self.OldSignatureModel.calls) == 1
|
|
assert "context_params" not in self.OldSignatureModel.calls[0][1]
|
|
|
|
def test_gpu_device_skips_context_params_when_signature_inspection_fails(self):
|
|
"""If Model.__init__ cannot be inspected, skip context_params safely."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.OldSignatureModel.calls = []
|
|
mock_pywhispercpp.Model = self.OldSignatureModel
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.inspect.signature",
|
|
side_effect=ValueError("no signature"),
|
|
):
|
|
result = manager._load_model_with_compatible_params(
|
|
"/fake/model.bin", {}, gpu_device=1
|
|
)
|
|
|
|
assert isinstance(result, self.OldSignatureModel)
|
|
assert len(self.OldSignatureModel.calls) == 1
|
|
assert "context_params" not in self.OldSignatureModel.calls[0][1]
|
|
|
|
def test_gpu_device_context_params_fallback_on_attribute_error(self):
|
|
"""AttributeError from pywhispercpp context_params is retried without it."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.AttributeErrorContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.AttributeErrorContextParamsModel
|
|
|
|
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 isinstance(result, self.AttributeErrorContextParamsModel)
|
|
assert len(self.AttributeErrorContextParamsModel.calls) == 2
|
|
assert self.AttributeErrorContextParamsModel.calls[0][1].get("context_params") == {
|
|
"gpu_device": 1
|
|
}
|
|
assert "context_params" not in self.AttributeErrorContextParamsModel.calls[1][1]
|
|
|
|
def test_gpu_device_skips_context_params_on_pywhispercpp_1_4(self):
|
|
"""AUR 1.4 wheels advertise context_params then crash; skip them (#625)."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
|
|
mock_pywhispercpp = MagicMock()
|
|
self.ContextParamsModel.calls = []
|
|
mock_pywhispercpp.Model = self.ContextParamsModel
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"pywhispercpp": mock_pywhispercpp,
|
|
"pywhispercpp.model": mock_pywhispercpp,
|
|
},
|
|
):
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager._pywhispercpp_distribution_version",
|
|
return_value=(1, 4, 0),
|
|
):
|
|
result = manager._load_model_with_compatible_params(
|
|
"/fake/model.bin", {}, gpu_device=1
|
|
)
|
|
|
|
assert isinstance(result, self.ContextParamsModel)
|
|
assert len(self.ContextParamsModel.calls) == 1
|
|
assert "context_params" not in self.ContextParamsModel.calls[0][1]
|
|
|
|
def test_host_cuda_label_runtime_vulkan_selects_discrete(self):
|
|
"""Device selection follows bundled Vulkan libs, not a host CUDA label (#604)."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
vulkan_devices = [
|
|
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
|
|
{"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"},
|
|
]
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
calls = self._init_with_backends(
|
|
manager,
|
|
host_backend=whispercpp_info.ComputeBackend.CUDA,
|
|
host_info="NVIDIA GeForce RTX 2060 (6 GiB)",
|
|
actual_backend="vulkan",
|
|
vulkan_devices=vulkan_devices,
|
|
prefer_index=1,
|
|
)
|
|
assert calls[-1][1].get("context_params") == {"gpu_device": 1}
|
|
|
|
def test_cpu_wheel_skips_device_when_host_reports_vulkan(self):
|
|
"""CPU-only pywhispercpp must not pass gpu_device even if vulkaninfo sees a GPU."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
vulkan_devices = [
|
|
{"index": 1, "name": "NVIDIA GeForce GTX 950M", "device_type": "discrete"},
|
|
]
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
with self.assertLogs(
|
|
"vocalinux.speech_recognition.recognition_manager", level="WARNING"
|
|
) as logs:
|
|
calls = self._init_with_backends(
|
|
manager,
|
|
host_backend=whispercpp_info.ComputeBackend.VULKAN,
|
|
host_info="NVIDIA GeForce GTX 950M",
|
|
actual_backend="cpu",
|
|
vulkan_devices=vulkan_devices,
|
|
prefer_index=1,
|
|
)
|
|
assert "context_params" not in calls[-1][1]
|
|
assert any("no GPU libraries" in message for message in logs.output)
|
|
|
|
def test_vulkan_runtime_without_enumerated_devices_skips_context_params(self):
|
|
"""Vulkan libs with no vulkaninfo devices leave gpu_device unset."""
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
with self.assertLogs(
|
|
"vocalinux.speech_recognition.recognition_manager", level="WARNING"
|
|
) as logs:
|
|
calls = self._init_with_backends(
|
|
manager,
|
|
host_backend=whispercpp_info.ComputeBackend.CUDA,
|
|
host_info="NVIDIA GeForce RTX 2060 (6 GiB)",
|
|
actual_backend="vulkan",
|
|
vulkan_devices=[],
|
|
prefer_index=None,
|
|
)
|
|
assert "context_params" not in calls[-1][1]
|
|
assert any("vulkaninfo found no devices" in message for message in logs.output)
|
|
|
|
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",
|
|
):
|
|
with patch.object(
|
|
manager,
|
|
"_detect_pywhispercpp_gpu_backend",
|
|
return_value="cpu",
|
|
):
|
|
manager._init_whispercpp()
|
|
assert (
|
|
"context_params"
|
|
not in mock_pywhispercpp.Model.call_args.kwargs
|
|
)
|
|
|
|
|
|
class TestWhispercppGgmlDeviceSelection(unittest.TestCase):
|
|
"""Test manager selection against whisper.cpp's native GPU registry."""
|
|
|
|
def setUp(self: "TestWhispercppGgmlDeviceSelection") -> None:
|
|
verifier = patch.object(
|
|
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
|
|
)
|
|
verifier.start()
|
|
self.addCleanup(verifier.stop)
|
|
self.device_helper = TestWhispercppGpuDeviceSelection()
|
|
|
|
def _load_with_ggml_devices(
|
|
self: "TestWhispercppGgmlDeviceSelection",
|
|
manager: SpeechRecognitionManager,
|
|
ggml_devices: list[dict[str, Any]],
|
|
vulkan_devices: list[dict[str, Any]],
|
|
actual_backend: str = "vulkan",
|
|
host_backend: Optional[str] = None,
|
|
) -> list[tuple[str, dict[str, Any]]]:
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
if host_backend is None:
|
|
host_backend = (
|
|
whispercpp_info.ComputeBackend.CUDA
|
|
if actual_backend == "cuda"
|
|
else whispercpp_info.ComputeBackend.VULKAN
|
|
)
|
|
with (
|
|
patch.object(
|
|
whispercpp_info,
|
|
"detect_ggml_gpu_devices",
|
|
return_value=ggml_devices,
|
|
),
|
|
patch(
|
|
"vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count",
|
|
return_value=8,
|
|
),
|
|
):
|
|
return self.device_helper._init_with_backends(
|
|
manager,
|
|
host_backend=host_backend,
|
|
host_info="Vulkan or CUDA test device",
|
|
actual_backend=actual_backend,
|
|
vulkan_devices=vulkan_devices,
|
|
prefer_index=1,
|
|
)
|
|
|
|
def test_auto_selection_uses_ggml_ordinal_not_vulkaninfo_index(
|
|
self: "TestWhispercppGgmlDeviceSelection",
|
|
) -> None:
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
vulkan_devices = [
|
|
{
|
|
"index": 0,
|
|
"name": "Intel(R) HD Graphics 4600 (HSW GT2)",
|
|
"device_type": "integrated",
|
|
},
|
|
{
|
|
"index": 1,
|
|
"name": "NVIDIA GeForce GTX 1050",
|
|
"device_type": "discrete",
|
|
},
|
|
]
|
|
ggml_devices = [
|
|
{
|
|
"index": 0,
|
|
"name": "NVIDIA GeForce GTX 1050",
|
|
"backend_name": "Vulkan0",
|
|
"device_type": "discrete",
|
|
}
|
|
]
|
|
|
|
with self.assertLogs(
|
|
"vocalinux.speech_recognition.recognition_manager", level="INFO"
|
|
) as logs:
|
|
calls = self._load_with_ggml_devices(
|
|
manager,
|
|
ggml_devices,
|
|
vulkan_devices,
|
|
host_backend=whispercpp_info.ComputeBackend.VULKAN,
|
|
)
|
|
|
|
self.assertEqual(calls[-1][1]["context_params"], {"gpu_device": 0})
|
|
self.assertEqual(calls[-1][1]["n_threads"], 2)
|
|
self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output))
|
|
|
|
def test_stale_explicit_index_falls_back_to_ggml_auto_selection(
|
|
self: "TestWhispercppGgmlDeviceSelection",
|
|
) -> None:
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
|
|
manager.model_size = "tiny"
|
|
ggml_devices = [
|
|
{
|
|
"index": 0,
|
|
"name": "NVIDIA GeForce GTX 1050",
|
|
"backend_name": "Vulkan0",
|
|
"device_type": "discrete",
|
|
}
|
|
]
|
|
vulkan_devices = [
|
|
{"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"},
|
|
{"index": 1, "name": "NVIDIA GeForce GTX 1050", "device_type": "discrete"},
|
|
]
|
|
|
|
with self.assertLogs(
|
|
"vocalinux.speech_recognition.recognition_manager", level="WARNING"
|
|
) as logs:
|
|
calls = self._load_with_ggml_devices(
|
|
manager,
|
|
ggml_devices,
|
|
vulkan_devices,
|
|
host_backend=whispercpp_info.ComputeBackend.VULKAN,
|
|
)
|
|
|
|
self.assertEqual(calls[-1][1]["context_params"], {"gpu_device": 0})
|
|
self.assertTrue(any("index 1 is unavailable" in entry for entry in logs.output))
|
|
self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output))
|
|
|
|
def test_empty_ggml_registry_uses_cpu_and_lists_skipped_devices(
|
|
self: "TestWhispercppGgmlDeviceSelection",
|
|
) -> None:
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
vulkan_devices = [
|
|
{
|
|
"index": 0,
|
|
"name": "Intel(R) HD Graphics 4600 (HSW GT2)",
|
|
"device_type": "integrated",
|
|
},
|
|
{
|
|
"index": 1,
|
|
"name": "NVIDIA GeForce GTX 1050",
|
|
"device_type": "discrete",
|
|
},
|
|
]
|
|
|
|
with self.assertLogs(
|
|
"vocalinux.speech_recognition.recognition_manager", level="INFO"
|
|
) as logs:
|
|
calls = self._load_with_ggml_devices(
|
|
manager,
|
|
[],
|
|
vulkan_devices,
|
|
host_backend=whispercpp_info.ComputeBackend.VULKAN,
|
|
)
|
|
|
|
self.assertEqual(calls[-1][1]["context_params"], {"use_gpu": False})
|
|
self.assertEqual(calls[-1][1]["n_threads"], 6)
|
|
self.assertTrue(any("effective cpu backend" in entry.lower() for entry in logs.output))
|
|
self.assertTrue(any("Intel(R) HD Graphics 4600" in entry for entry in logs.output))
|
|
self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output))
|
|
|
|
def test_positive_thread_override_applies_to_cpu_and_gpu(
|
|
self: "TestWhispercppGgmlDeviceSelection",
|
|
) -> None:
|
|
manager = _make_manager(
|
|
engine="whisper_cpp",
|
|
whispercpp_gpu_device=None,
|
|
whispercpp_n_threads=3,
|
|
)
|
|
manager.model_size = "tiny"
|
|
vulkan_devices = [
|
|
{"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"},
|
|
{"index": 1, "name": "NVIDIA GPU", "device_type": "discrete"},
|
|
]
|
|
ggml_devices = [
|
|
{
|
|
"index": 0,
|
|
"name": "NVIDIA GPU",
|
|
"backend_name": "Vulkan0",
|
|
"device_type": "discrete",
|
|
}
|
|
]
|
|
|
|
gpu_calls = self._load_with_ggml_devices(manager, ggml_devices, vulkan_devices)
|
|
self.assertEqual(gpu_calls[-1][1]["n_threads"], 3)
|
|
self.assertEqual(gpu_calls[-1][1]["context_params"], {"gpu_device": 0})
|
|
|
|
cpu_manager = _make_manager(
|
|
engine="whisper_cpp",
|
|
whispercpp_gpu_device=None,
|
|
whispercpp_n_threads=3,
|
|
)
|
|
cpu_manager.model_size = "tiny"
|
|
cpu_calls = self._load_with_ggml_devices(cpu_manager, [], vulkan_devices)
|
|
self.assertEqual(cpu_calls[-1][1]["n_threads"], 3)
|
|
self.assertEqual(cpu_calls[-1][1]["context_params"], {"use_gpu": False})
|
|
|
|
def test_cuda_runtime_with_empty_ggml_registry_uses_cpu_threads(
|
|
self: "TestWhispercppGgmlDeviceSelection",
|
|
) -> None:
|
|
import vocalinux.utils.whispercpp_model_info as whispercpp_info
|
|
|
|
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
|
|
manager.model_size = "tiny"
|
|
vulkan_devices = [
|
|
{"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"},
|
|
{"index": 1, "name": "NVIDIA GeForce GTX 1050", "device_type": "discrete"},
|
|
]
|
|
with self.assertLogs(
|
|
"vocalinux.speech_recognition.recognition_manager", level="INFO"
|
|
) as logs:
|
|
calls = self._load_with_ggml_devices(
|
|
manager,
|
|
[],
|
|
vulkan_devices,
|
|
actual_backend="cuda",
|
|
host_backend=whispercpp_info.ComputeBackend.CUDA,
|
|
)
|
|
|
|
self.assertEqual(calls[-1][1]["n_threads"], 6)
|
|
self.assertEqual(calls[-1][1]["context_params"], {"use_gpu": False})
|
|
self.assertTrue(any("effective cpu backend" in entry.lower() for entry in logs.output))
|
|
|
|
|
|
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"):
|
|
assert manager.start_recognition() is False
|
|
# 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"):
|
|
assert manager.start_recognition() is False
|
|
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"):
|
|
assert manager.start_recognition() is True
|
|
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", force_download=False)
|
|
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()
|
|
# Segments carry the session language they were recorded under (#805).
|
|
assert manager._segment_queue.get_nowait() == (buf, "en-us")
|
|
|
|
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"], "en-us"))
|
|
manager._enqueue_audio_segment([b"new"])
|
|
assert manager._segment_queue.get_nowait() == ([b"new"], "en-us")
|
|
|
|
|
|
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"], "en-us"))
|
|
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"], "en-us"))
|
|
manager._segment_queue.put(([b"second"], "en-us"))
|
|
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()
|
|
|
|
def _track_states(self, manager):
|
|
states = []
|
|
original = manager._update_state
|
|
|
|
def _tracked(new_state):
|
|
states.append(new_state)
|
|
original(new_state)
|
|
|
|
manager._update_state = _tracked
|
|
return states
|
|
|
|
def test_leftover_after_stop_does_not_stick_in_processing(self):
|
|
"""After stop, leftover audio is transcribed but state returns to IDLE.
|
|
|
|
Reproduces #739: stop_recognition can time out while the recognition
|
|
thread still marks leftover drain as PROCESSING and never goes idle.
|
|
The tray then stays on the active/processing icon until another
|
|
Alt+F12 toggle.
|
|
"""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = False
|
|
manager.state = RecognitionState.IDLE
|
|
states = self._track_states(manager)
|
|
|
|
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
|
manager._segment_queue.put(([b"leftover"], "en-us"))
|
|
manager._segment_queue.put(None)
|
|
t = threading.Thread(target=manager._perform_recognition)
|
|
t.start()
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
mock_proc.assert_called_once_with([b"leftover"], "en-us")
|
|
|
|
assert RecognitionState.PROCESSING not in states
|
|
assert RecognitionState.LISTENING not in states
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
def test_none_signal_drain_after_stop_returns_idle(self):
|
|
"""Segments still queued behind the stop sentinel must not leave PROCESSING."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = False
|
|
manager.state = RecognitionState.IDLE
|
|
states = self._track_states(manager)
|
|
|
|
with patch.object(manager, "_process_audio_buffer") as mock_proc:
|
|
# None is consumed first; leftover is drained from the remaining queue.
|
|
manager._segment_queue.put(None)
|
|
manager._segment_queue.put(([b"leftover"], "en-us"))
|
|
t = threading.Thread(target=manager._perform_recognition)
|
|
t.start()
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
mock_proc.assert_called_once_with([b"leftover"], "en-us")
|
|
|
|
assert RecognitionState.PROCESSING not in states
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
def test_processing_still_set_while_recording(self):
|
|
"""Live dictation still flips PROCESSING then LISTENING."""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = True
|
|
manager.state = RecognitionState.LISTENING
|
|
states = self._track_states(manager)
|
|
processed = threading.Event()
|
|
|
|
def _after_process(_segment, _language):
|
|
processed.set()
|
|
|
|
with patch.object(manager, "_process_audio_buffer", side_effect=_after_process):
|
|
t = threading.Thread(target=manager._perform_recognition)
|
|
t.start()
|
|
manager._segment_queue.put(([b"live"], "en-us"))
|
|
assert processed.wait(timeout=2)
|
|
deadline = time.time() + 1
|
|
while RecognitionState.LISTENING not in states and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
manager.should_record = False
|
|
manager._segment_queue.put(None)
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
|
|
assert RecognitionState.PROCESSING in states
|
|
assert RecognitionState.LISTENING in states
|
|
assert manager.state == RecognitionState.LISTENING
|
|
|
|
def test_leftover_exit_does_not_clobber_new_listening_session(self):
|
|
"""Leftover drain finishing after a new start must not force IDLE.
|
|
|
|
stop_recognition() join can time out, then Alt+F12 starts again.
|
|
start_recognition() sets LISTENING before should_record. If the
|
|
leftover worker forced IDLE on exit, the tray would go idle and
|
|
the next hotkey would start a second session.
|
|
"""
|
|
manager = _make_manager()
|
|
manager._segment_queue = queue.Queue()
|
|
manager.should_record = False
|
|
manager.state = RecognitionState.IDLE
|
|
states = self._track_states(manager)
|
|
|
|
def _start_new_session(_segment, _language):
|
|
manager.state = RecognitionState.LISTENING
|
|
|
|
with patch.object(manager, "_process_audio_buffer", side_effect=_start_new_session):
|
|
manager._segment_queue.put(([b"leftover"], "en-us"))
|
|
manager._segment_queue.put(None)
|
|
t = threading.Thread(target=manager._perform_recognition)
|
|
t.start()
|
|
t.join(timeout=2)
|
|
assert not t.is_alive()
|
|
|
|
assert manager.state == RecognitionState.LISTENING
|
|
assert RecognitionState.IDLE not in states
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|