diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index c00fe5a..19c5ef0 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -137,6 +137,26 @@ def _pywhispercpp_distribution_version() -> Optional[tuple[int, ...]]: return tuple(parts) if parts else None +def _select_ggml_gpu_device( + devices: list[dict[str, Any]], requested_index: Optional[int] +) -> Optional[dict[str, Any]]: + if requested_index is not None and requested_index >= 0: + selected = next((device for device in devices if device["index"] == requested_index), None) + if selected is not None: + return selected + logger.warning( + "Requested whisper.cpp GPU index %s is unavailable; usable ggml GPU devices: %s. " + "Selecting automatically.", + requested_index, + devices, + ) + + return next( + (device for device in devices if device["device_type"] == "discrete"), + devices[0] if devices else None, + ) + + def resolve_whisper_language(language: str) -> Optional[str]: """Map a catalog language id to a Whisper / whisper.cpp language code. @@ -1515,12 +1535,21 @@ class SpeechRecognitionManager: return compatible_kwargs def _load_model_with_compatible_params( - self, model_path: str, model_kwargs: dict, gpu_device: Optional[int] = None - ): + self, + model_path: str, + model_kwargs: dict[str, Any], + gpu_device: Optional[int] = None, + use_gpu: Optional[bool] = None, + ) -> Any: from pywhispercpp.model import Model compatible_kwargs = self._filter_whispercpp_model_kwargs(model_kwargs) + context_params: dict[str, Any] = {} + if use_gpu is not None: + context_params["use_gpu"] = use_gpu if gpu_device is not None and gpu_device >= 0: + context_params["gpu_device"] = gpu_device + if context_params: dist_version = _pywhispercpp_distribution_version() too_old_for_context_params = dist_version is not None and dist_version < (1, 5, 0) try: @@ -1540,7 +1569,7 @@ class SpeechRecognitionManager: version_label, ) elif supports_context_params: - compatible_kwargs["context_params"] = {"gpu_device": gpu_device} + compatible_kwargs["context_params"] = context_params else: logger.warning( "pywhispercpp does not support context_params; " @@ -1582,62 +1611,118 @@ class SpeechRecognitionManager: _prefer_discrete_vulkan_device, detect_compute_backend, detect_cpu_info, + detect_ggml_gpu_devices, detect_vulkan_devices, get_backend_display_name, ) host_backend, host_info = detect_compute_backend() actual_gpu_backend = self._detect_pywhispercpp_gpu_backend() + ggml_devices = detect_ggml_gpu_devices() logger.info("whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") selected_gpu_device = None + use_gpu: Optional[bool] = None runtime_backend = ComputeBackend.CPU runtime_info = detect_cpu_info() if actual_gpu_backend == "cuda": - # pywhispercpp CUDA uses CUDA device ordinals (0 = first NVIDIA GPU). - # Vulkan enumeration on hybrid laptops lists iGPU as GPU0 and dGPU as - # GPU1; passing that Vulkan index to CUDA selects the CPU fallback. - vulkan_gpu_index = self.whispercpp_gpu_device - if vulkan_gpu_index is None or vulkan_gpu_index < 0: - vulkan_gpu_index = _prefer_discrete_vulkan_device() - if vulkan_gpu_index not in (None, 0): - logger.info( - "pywhispercpp is CUDA-backed; using CUDA device 0 " - f"(Vulkan GPU index {vulkan_gpu_index} does not map to CUDA ordinals)" + if ggml_devices == []: + use_gpu = False + skipped_devices = [ + device["name"] + for device in detect_vulkan_devices() + if device.get("device_type") != "software" + ] + logger.warning( + "whisper.cpp found no usable ggml GPU device and will use CPU. " + "Vulkan hardware devices skipped by ggml: %s", + ", ".join(skipped_devices) if skipped_devices else "none found", ) else: - logger.info("pywhispercpp is CUDA-backed; using CUDA device 0") - selected_gpu_device = 0 - runtime_backend = ComputeBackend.CUDA - runtime_info = host_info if host_backend == ComputeBackend.CUDA else "NVIDIA GPU" + # CUDA device ordinals are independent of vulkaninfo indices. + vulkan_gpu_index = self.whispercpp_gpu_device + if vulkan_gpu_index is None or vulkan_gpu_index < 0: + vulkan_gpu_index = _prefer_discrete_vulkan_device() + if vulkan_gpu_index not in (None, 0): + logger.info( + "pywhispercpp is CUDA-backed; using CUDA device 0 " + "(Vulkan GPU index %s does not map to CUDA ordinals)", + vulkan_gpu_index, + ) + else: + logger.info("pywhispercpp is CUDA-backed; using CUDA device 0") + selected_gpu_device = 0 + runtime_backend = ComputeBackend.CUDA + runtime_info = host_info if host_backend == ComputeBackend.CUDA else "NVIDIA GPU" elif actual_gpu_backend == "vulkan": - gpu_device_index = self.whispercpp_gpu_device - if gpu_device_index is None or gpu_device_index < 0: - gpu_device_index = _prefer_discrete_vulkan_device() - - devices = detect_vulkan_devices() - runtime_backend = ComputeBackend.VULKAN - if gpu_device_index is not None: - device_name = next( - (d["name"] for d in devices if d["index"] == gpu_device_index), - "unknown", - ) - logger.info(f"Using Vulkan GPU [{gpu_device_index}]: {device_name}") - selected_gpu_device = gpu_device_index - runtime_info = device_name - elif devices: - runtime_info = devices[0]["name"] - logger.warning( - "pywhispercpp is Vulkan-backed but no preferred GPU was selected. " - "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." - ) + if ggml_devices is not None: + vulkan_devices = detect_vulkan_devices() + if ggml_devices: + selected_device = _select_ggml_gpu_device( + ggml_devices, self.whispercpp_gpu_device + ) + ggml_names = {device["name"] for device in ggml_devices} + skipped_devices = [ + device["name"] + for device in vulkan_devices + if device.get("device_type") != "software" + and device["name"] not in ggml_names + ] + if skipped_devices: + logger.info( + "Vulkan devices not registered by ggml: %s", + ", ".join(skipped_devices), + ) + if selected_device is not None: + runtime_backend = ComputeBackend.VULKAN + selected_gpu_device = selected_device["index"] + runtime_info = selected_device["name"] + logger.info( + "Requesting Vulkan GPU [%s]: %s (%s)", + selected_device["index"], + selected_device["name"], + selected_device["backend_name"], + ) + else: + use_gpu = False + skipped_devices = [ + device["name"] + for device in vulkan_devices + if device.get("device_type") != "software" + ] + logger.warning( + "whisper.cpp found no usable Vulkan device and will use CPU. " + "Vulkan hardware devices skipped by ggml: %s", + ", ".join(skipped_devices) if skipped_devices else "none found", + ) else: - logger.warning( - "pywhispercpp is Vulkan-backed but vulkaninfo found no devices. " - "Install vulkan-tools so Vocalinux can pick a discrete GPU. " - "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." - ) + gpu_device_index = self.whispercpp_gpu_device + if gpu_device_index is None or gpu_device_index < 0: + gpu_device_index = _prefer_discrete_vulkan_device() + + devices = detect_vulkan_devices() + runtime_backend = ComputeBackend.VULKAN + if gpu_device_index is not None: + device_name = next( + (d["name"] for d in devices if d["index"] == gpu_device_index), + "unknown", + ) + logger.info(f"Using Vulkan GPU [{gpu_device_index}]: {device_name}") + selected_gpu_device = gpu_device_index + runtime_info = device_name + elif devices: + runtime_info = devices[0]["name"] + logger.warning( + "pywhispercpp is Vulkan-backed but no preferred GPU was selected. " + "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." + ) + else: + logger.warning( + "pywhispercpp is Vulkan-backed but vulkaninfo found no devices. " + "Install vulkan-tools so Vocalinux can pick a discrete GPU. " + "whisper.cpp will default to GPU 0, which may be an iGPU on hybrid laptops." + ) elif host_backend != ComputeBackend.CPU: logger.warning( "Host reports %s (%s), but pywhispercpp has no GPU libraries. " @@ -1649,7 +1734,11 @@ class SpeechRecognitionManager: host_info, ) - if actual_gpu_backend in ("vulkan", "cuda") and host_backend != actual_gpu_backend: + if ( + runtime_backend == actual_gpu_backend + and actual_gpu_backend in ("vulkan", "cuda") + and host_backend != actual_gpu_backend + ): logger.info( "Host capability label is %s; bundled pywhispercpp libraries are %s. " "Using the bundled %s backend.", @@ -1659,7 +1748,9 @@ class SpeechRecognitionManager: ) logger.info( - f"whisper.cpp using {get_backend_display_name(runtime_backend)} backend: {runtime_info}" + "whisper.cpp requesting %s backend: %s", + get_backend_display_name(runtime_backend), + runtime_info, ) import psutil @@ -1678,9 +1769,8 @@ class SpeechRecognitionManager: logger.info(f"Loading whisper.cpp '{self.model_size}' model...") self.model = None # Release previous model if re-initializing - has_gpu_libs = actual_gpu_backend in ("vulkan", "cuda") - - if has_gpu_libs and not ( + has_gpu_backend = runtime_backend in (ComputeBackend.VULKAN, ComputeBackend.CUDA) + if has_gpu_backend and not ( self.whispercpp_n_threads is not None and self.whispercpp_n_threads > 0 ): n_threads = max(1, multiprocessing.cpu_count() // 4) @@ -1695,18 +1785,25 @@ class SpeechRecognitionManager: try: self.model = self._load_model_with_compatible_params( - model_path, model_kwargs, gpu_device=selected_gpu_device + model_path, model_kwargs, gpu_device=selected_gpu_device, use_gpu=use_gpu ) except RuntimeError as model_error: loaded_backend = self._handle_gpu_fallback( model_error, model_path, model_kwargs, ComputeBackend.CPU ) + runtime_info = detect_cpu_info() effective_threads = self._whispercpp_effective_threads if effective_threads is None: effective_threads = n_threads load_duration = time.time() - load_start_time + logger.info( + "whisper.cpp loaded with effective %s backend: %s (n_threads=%d)", + get_backend_display_name(loaded_backend), + runtime_info, + effective_threads, + ) logger.info( f"whisper.cpp configured with n_threads={effective_threads} " f"({loaded_backend} backend)" diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 9f10287..710113b 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2422,6 +2422,24 @@ class ModelDownloadDialog(Gtk.Dialog): self.add_button("OK", Gtk.ResponseType.OK) +def _populate_gpu_device_choices(combo: Any, saved_device: Optional[int]) -> None: + combo.remove_all() + combo.append("-1", "Auto (discrete GPU)") + + from ..utils.whispercpp_model_info import detect_ggml_gpu_devices + + devices = detect_ggml_gpu_devices() + if devices is None: + devices = detect_vulkan_devices() + for device in devices: + type_label = device["device_type"].capitalize() + label = f"[{device['index']}] {device['name']} ({type_label})" + combo.append(str(device["index"]), label) + + if saved_device is None or not combo.set_active_id(str(saved_device)): + combo.set_active_id("-1") + + class SettingsDialog(AboutSectionMixin): """Modern GTK Dialog for configuring Vocalinux settings.""" @@ -9592,23 +9610,10 @@ For now, the engine has been reverted to VOSK.""" error_dialog.destroy() return False - def _populate_gpu_devices(self): + def _populate_gpu_devices(self) -> None: """Populate the GPU device dropdown with available Vulkan devices.""" - self.gpu_device_combo.remove_all() - self.gpu_device_combo.append("-1", "Auto (discrete GPU)") - - devices = detect_vulkan_devices() - for device in devices: - type_label = device["device_type"].capitalize() - label = f"[{device['index']}] {device['name']} ({type_label})" - self.gpu_device_combo.append(str(device["index"]), label) - saved_device = self.config_manager.get("advanced", "whispercpp_gpu_device", None) - if saved_device is None: - self.gpu_device_combo.set_active_id("-1") - else: - if not self.gpu_device_combo.set_active_id(str(saved_device)): - self.gpu_device_combo.set_active_id("-1") + _populate_gpu_device_choices(self.gpu_device_combo, saved_device) def _populate_audio_devices(self): """Populate the audio device dropdown with available input devices.""" diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 1ddf6e4..961f6e6 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -5,13 +5,15 @@ This module provides model metadata and hardware acceleration detection for whisper.cpp, supporting Vulkan, CUDA, and CPU backends. """ +import ctypes import logging import math import os import re import subprocess -from functools import lru_cache -from typing import Optional, cast +from functools import cache, lru_cache +from importlib.metadata import PackageNotFoundError, version +from typing import Any, Optional, cast from .host_process import host_env from .model_checksums import whispercpp_revision @@ -347,6 +349,61 @@ def detect_vulkan_devices() -> list[dict]: return devices +@cache +def detect_ggml_gpu_devices() -> Optional[list[dict[str, Any]]]: + """Return whisper.cpp's usable GPU devices in its device-selection order.""" + try: + raw_version = version("pywhispercpp") + except (ImportError, PackageNotFoundError): + return None + + version_match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?", raw_version) + if version_match is None: + return None + installed_version = tuple(int(part or 0) for part in version_match.groups()) + if installed_version < (1, 5, 0): + return None + + try: + from . import pywhispercpp_loader + + pywhispercpp_loader.preload_shared_libraries() + import _pywhispercpp + + library = ctypes.CDLL(_pywhispercpp.__file__) + library.ggml_backend_dev_count.argtypes = [] + library.ggml_backend_dev_count.restype = ctypes.c_size_t + library.ggml_backend_dev_get.argtypes = [ctypes.c_size_t] + library.ggml_backend_dev_get.restype = ctypes.c_void_p + library.ggml_backend_dev_name.argtypes = [ctypes.c_void_p] + library.ggml_backend_dev_name.restype = ctypes.c_char_p + library.ggml_backend_dev_description.argtypes = [ctypes.c_void_p] + library.ggml_backend_dev_description.restype = ctypes.c_char_p + library.ggml_backend_dev_type.argtypes = [ctypes.c_void_p] + library.ggml_backend_dev_type.restype = ctypes.c_int + + devices: list[dict[str, Any]] = [] + for registry_index in range(library.ggml_backend_dev_count()): + device = library.ggml_backend_dev_get(registry_index) + device_type = library.ggml_backend_dev_type(device) + if device_type not in (1, 2): + continue + # whisper.cpp counts every GPU/IGPU device, so never skip one here. + backend_name = library.ggml_backend_dev_name(device) or b"unknown" + description = library.ggml_backend_dev_description(device) or backend_name + devices.append( + { + "index": len(devices), + "name": description.decode("utf-8", errors="replace"), + "backend_name": backend_name.decode("utf-8", errors="replace"), + "device_type": "discrete" if device_type == 1 else "integrated", + } + ) + return devices + except (ImportError, OSError, AttributeError): + return None + + def _prefer_discrete_vulkan_device() -> Optional[int]: """Return the index of the preferred hardware Vulkan GPU (discrete if available).""" devices = _hardware_vulkan_devices() diff --git a/tests/conftest.py b/tests/conftest.py index 1bc61d5..4d4d06e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -210,6 +210,7 @@ def _clear_hardware_detection_cache(): "detect_cuda_support", "detect_compute_backend", "detect_cpu_info", + "detect_ggml_gpu_devices", ): fn = getattr(_wmi, fn_name, None) if fn is not None and hasattr(fn, "cache_clear"): @@ -219,6 +220,20 @@ def _clear_hardware_detection_cache(): yield +@pytest.fixture(autouse=True) +def _use_legacy_ggml_gpu_detection( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None, None, None]: + """Keep existing tests on the legacy path unless they opt into ggml probing.""" + from vocalinux.utils import whispercpp_model_info as _wmi + + def _return_none() -> None: + return None + + monkeypatch.setattr(_wmi, "detect_ggml_gpu_devices", _return_none) + yield + + @pytest.fixture(autouse=True) def _cleanup_ibus_server(): """Stop any leftover IBus socket server threads after each test. diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 0ef06ef..bb83948 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -20,6 +20,7 @@ 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 @@ -1977,6 +1978,228 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): ) +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.""" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 035b4e6..db49d21 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -16,6 +16,7 @@ import sys import textwrap import time import unittest +from typing import Optional from unittest.mock import MagicMock, Mock, call, patch # Mock GTK before importing anything that might use it @@ -1719,5 +1720,82 @@ class TestSettingsControlColumn(unittest.TestCase): self.assertNotIn("Tip: ", self.source) +def _gpu_settings_dialog(saved_device: Optional[int]) -> MagicMock: + dialog = MagicMock() + dialog.config_manager.get.return_value = saved_device + dialog.gpu_device_combo.set_active_id.return_value = True + return dialog + + +def test_gpu_dropdown_uses_ggml_indices_and_labels() -> None: + from vocalinux.ui import settings_dialog + from vocalinux.utils import whispercpp_model_info + + dialog = _gpu_settings_dialog(None) + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan0", + "device_type": "discrete", + }, + { + "index": 1, + "name": "Intel Arc", + "backend_name": "Vulkan1", + "device_type": "integrated", + }, + ] + with patch.object(whispercpp_model_info, "detect_ggml_gpu_devices", return_value=ggml_devices): + settings_dialog._populate_gpu_device_choices(dialog.gpu_device_combo, None) + + dialog.gpu_device_combo.append.assert_any_call("0", "[0] NVIDIA GeForce GTX 1050 (Discrete)") + dialog.gpu_device_combo.append.assert_any_call("1", "[1] Intel Arc (Integrated)") + + +def test_gpu_dropdown_falls_back_to_vulkaninfo_when_ggml_probe_is_unavailable() -> None: + from vocalinux.ui import settings_dialog + from vocalinux.utils import whispercpp_model_info + + dialog = _gpu_settings_dialog(None) + vulkan_devices = [ + {"index": 1, "name": "NVIDIA GPU", "device_type": "discrete"}, + ] + with ( + patch.object(whispercpp_model_info, "detect_ggml_gpu_devices", return_value=None), + patch.object(settings_dialog, "detect_vulkan_devices", return_value=vulkan_devices), + ): + settings_dialog._populate_gpu_device_choices(dialog.gpu_device_combo, None) + + dialog.gpu_device_combo.append.assert_any_call("1", "[1] NVIDIA GPU (Discrete)") + + +def test_gpu_dropdown_selects_auto_for_stale_saved_index() -> None: + from vocalinux.ui import settings_dialog + from vocalinux.utils import whispercpp_model_info + + dialog = _gpu_settings_dialog(9) + + def _set_active_id(device_id: str) -> bool: + return device_id in {"-1", "0"} + + dialog.gpu_device_combo.set_active_id.side_effect = _set_active_id + ggml_devices = [ + { + "index": 0, + "name": "NVIDIA GPU", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ] + with patch.object(whispercpp_model_info, "detect_ggml_gpu_devices", return_value=ggml_devices): + settings_dialog._populate_gpu_device_choices(dialog.gpu_device_combo, 9) + + assert dialog.gpu_device_combo.set_active_id.call_args_list[-2:] == [ + call("9"), + call("-1"), + ] + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index b779e93..45cf9f3 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -5,9 +5,63 @@ Tests all functions and constants in the module. """ import subprocess +import sys import unittest +from types import SimpleNamespace +from typing import Any, Optional from unittest.mock import MagicMock, patch +from vocalinux.utils import whispercpp_model_info as _whispercpp_model_info + +_REAL_GGML_DEVICE_PROBE = _whispercpp_model_info.detect_ggml_gpu_devices + + +def _fake_ggml_library(registry: list[tuple[str, str, int]]) -> MagicMock: + library = MagicMock() + library.ggml_backend_dev_count.return_value = len(registry) + + def _get_device(index: int) -> int: + return index + 1 + + def _get_device_type(pointer: int) -> int: + return registry[pointer - 1][2] + + def _get_device_name(pointer: int) -> bytes: + return registry[pointer - 1][0].encode("utf-8") + + def _get_device_description(pointer: int) -> bytes: + return registry[pointer - 1][1].encode("utf-8") + + library.ggml_backend_dev_get.side_effect = _get_device + library.ggml_backend_dev_type.side_effect = _get_device_type + library.ggml_backend_dev_name.side_effect = _get_device_name + library.ggml_backend_dev_description.side_effect = _get_device_description + return library + + +def _run_ggml_probe( + registry: list[tuple[str, str, int]], + version_string: str = "1.5.0", + library: Optional[Any] = None, + library_error: Optional[Exception] = None, + missing_extension: bool = False, +) -> Optional[list[dict[str, Any]]]: + _REAL_GGML_DEVICE_PROBE.cache_clear() + native_library = library if library is not None else _fake_ggml_library(registry) + extension = None if missing_extension else SimpleNamespace(__file__="/fake/_pywhispercpp.so") + from vocalinux.utils import pywhispercpp_loader + + with ( + patch.object(_whispercpp_model_info, "version", return_value=version_string), + patch.object(pywhispercpp_loader, "preload_shared_libraries"), + patch.dict(sys.modules, {"_pywhispercpp": extension}), + ): + if library_error is not None: + with patch.object(_whispercpp_model_info.ctypes, "CDLL", side_effect=library_error): + return _REAL_GGML_DEVICE_PROBE() + with patch.object(_whispercpp_model_info.ctypes, "CDLL", return_value=native_library): + return _REAL_GGML_DEVICE_PROBE() + class TestComputeBackendClass(unittest.TestCase): """Test cases for ComputeBackend class.""" @@ -435,7 +489,105 @@ class TestPreferDiscreteVulkanDevice(unittest.TestCase): from vocalinux.utils.whispercpp_model_info import _prefer_discrete_vulkan_device preferred_idx = _prefer_discrete_vulkan_device() - self.assertEqual(preferred_idx, 0) + self.assertEqual(preferred_idx, 0) + + +class TestDetectGgmlGpuDevices(unittest.TestCase): + """Test ggml's native GPU registry probe.""" + + def test_probe_assigns_zero_to_first_discrete_gpu( + self: "TestDetectGgmlGpuDevices", + ) -> None: + devices = _run_ggml_probe( + [ + ("Vulkan0", "NVIDIA GeForce GTX 1050", 1), + ("CPU", "CPU backend", 0), + ] + ) + + self.assertEqual( + devices, + [ + { + "index": 0, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan0", + "device_type": "discrete", + } + ], + ) + + def test_probe_uses_gpu_and_igpu_ordinals(self: "TestDetectGgmlGpuDevices") -> None: + devices = _run_ggml_probe( + [ + ("Vulkan0", "Intel Arc", 2), + ("CPU", "CPU backend", 0), + ("Vulkan1", "NVIDIA GeForce GTX 1050", 1), + ("ACCEL", "Accelerator", 3), + ] + ) + + self.assertEqual( + devices, + [ + { + "index": 0, + "name": "Intel Arc", + "backend_name": "Vulkan0", + "device_type": "integrated", + }, + { + "index": 1, + "name": "NVIDIA GeForce GTX 1050", + "backend_name": "Vulkan1", + "device_type": "discrete", + }, + ], + ) + + def test_probe_returns_empty_list_for_cpu_only_registry( + self: "TestDetectGgmlGpuDevices", + ) -> None: + self.assertEqual(_run_ggml_probe([("CPU", "CPU backend", 0)]), []) + + def test_probe_returns_none_when_runtime_is_unavailable( + self: "TestDetectGgmlGpuDevices", + ) -> None: + self.assertIsNone(_run_ggml_probe([], version_string="1.4.9")) + self.assertIsNone(_run_ggml_probe([], missing_extension=True)) + self.assertIsNone(_run_ggml_probe([], library_error=OSError("unavailable"))) + self.assertIsNone(_run_ggml_probe([], library=SimpleNamespace())) + + def test_probe_keeps_ordinal_for_device_without_description( + self: "TestDetectGgmlGpuDevices", + ) -> None: + library = _fake_ggml_library( + [("Vulkan0", "", 1), ("Vulkan1", "NVIDIA GeForce GTX 1050", 1)] + ) + library.ggml_backend_dev_description.side_effect = lambda pointer: ( + None if pointer == 1 else b"NVIDIA GeForce GTX 1050" + ) + + devices = _run_ggml_probe([], library=library) + + assert devices is not None + self.assertEqual([device["index"] for device in devices], [0, 1]) + self.assertEqual(devices[0]["name"], "Vulkan0") + self.assertEqual(devices[1]["name"], "NVIDIA GeForce GTX 1050") + + def test_probe_accepts_prerelease_version(self: "TestDetectGgmlGpuDevices") -> None: + devices = _run_ggml_probe([("Vulkan0", "GPU", 1)], version_string="1.5.1.dev3") + assert devices is not None + self.assertEqual(len(devices), 1) + + +class TestPreferDiscreteVulkanDeviceMore(unittest.TestCase): + """Exercise additional Vulkan selection edge cases.""" + + def setUp(self: "TestPreferDiscreteVulkanDeviceMore") -> None: + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + detect_vulkan_devices.cache_clear() def test_prefer_discrete_returns_none_when_empty(self): """Test that None is returned when no devices found."""