diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9e6c6de..e3d5351 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -49,7 +49,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -97,6 +102,8 @@ jobs: ls -la dist/ - name: Build AppImage (x86_64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ steps.version.outputs.version }}" dist ls -la dist/*.AppImage @@ -298,7 +305,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -310,6 +322,8 @@ jobs: ls -la dist/ - name: Build AppImage (aarch64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ needs.nightly.outputs.version }}" dist ls -la dist/*.AppImage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 930c9bd..6d920a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -82,6 +87,8 @@ jobs: ls -la dist/ - name: Build AppImage (x86_64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist ls -la dist/*.AppImage @@ -177,7 +184,12 @@ jobs: gir1.2-rsvg-2.0 \ librsvg2-common \ libdbusmenu-gtk3-4 \ - libnotify4 + libnotify4 \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install build dependencies run: | @@ -196,6 +208,8 @@ jobs: ls -la dist/ - name: Build AppImage (aarch64) + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | bash packaging/appimage/build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist ls -la dist/*.AppImage diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 0677527..975310d 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -213,7 +213,12 @@ jobs: gobject-introspection \ libcairo-gobject2 \ gir1.2-gtk-3.0 \ - portaudio19-dev + portaudio19-dev \ + cmake \ + ninja-build \ + build-essential \ + libvulkan-dev \ + glslc - name: Install GObject introspection dependencies run: | @@ -227,6 +232,8 @@ jobs: ls -la dist/ - name: Build AppImage + env: + VOCALINUX_APPIMAGE_REQUIRE_VULKAN: "1" run: | VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py) WHEEL=$(ls dist/*.whl) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4cb80bc..0ec6648 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -38,8 +38,11 @@ chmod +x Vocalinux-*-x86_64.AppImage # or aarch64 ``` AppImage still needs host text-injection tools (`xdotool` on X11; `wtype` / -`ydotool` / clipboard tools on Wayland), same as the PyPI path. Prefer the -one-liner installer above when you want system deps and models set up for you. +`ydotool` / clipboard tools on Wayland), same as the PyPI path. Current +AppImages rebuild whisper.cpp with Vulkan and use the host GPU driver; you +still need working Vulkan on the machine (`vulkaninfo --summary`). Prefer the +one-liner installer above when you want system deps, a CUDA build, and models +set up for you. ### From PyPI @@ -501,6 +504,11 @@ vulkaninfo --summary | grep -i "deviceName" # In Vocalinux, look for these log messages: # [INFO] whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU # [INFO] whisper.cpp using Vulkan GPU backend: AMD Radeon RX 6800 +# [INFO] whisper.cpp configured with n_threads=4 (GPU backend: vulkan) + +# If you instead see "CPU-only; pywhispercpp lacks GPU libraries", the +# bundled pywhispercpp was built without GPU libs (older AppImage, or a +# CPU pip wheel). install.sh rebuilds it with Vulkan/CUDA when possible. ``` ### Reusing Existing whisper.cpp Builds diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8b8653a..6fd7263 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -159,22 +159,31 @@ is hidden when there is nothing unused to delete. ### GPU Acceleration -**whisper.cpp** automatically uses GPU acceleration when available: +**whisper.cpp** uses GPU acceleration when the bundled pywhispercpp libraries include Vulkan or CUDA: -- **Vulkan** (AMD, Intel, NVIDIA) - Automatically detected and used -- **CUDA** (NVIDIA only) - Fallback if Vulkan not available -- **CPU** - Always works as fallback +- **Vulkan** (AMD, Intel, NVIDIA) when `libggml-vulkan` is present +- **CUDA** (NVIDIA) when `libggml-cuda` is present +- **CPU** when those libraries are missing, even if Settings lists a GPU -On multi-GPU machines, Vocalinux prefers a **discrete** Vulkan device when one is present. You can override the device under **Advanced** settings (`whispercpp_gpu_device`). +Host tools such as `vulkaninfo` only describe the machine. The engine follows the libraries actually loaded. Look for `GPU backend: vulkan` or `GPU backend: cuda` after the model loads. `CPU-only; pywhispercpp lacks GPU libraries` means inference is on the CPU. + +On multi-GPU machines, Vocalinux prefers a discrete Vulkan device when one is present and skips software renderers such as llvmpipe. Override the device under **Settings → Performance → Vulkan GPU**. Pip wheels of pywhispercpp are often CUDA builds. In that case Vocalinux always uses CUDA device 0 (the first NVIDIA GPU), because Vulkan GPU indices do not match CUDA ordinals. On a hybrid laptop the NVIDIA GPU is usually Vulkan GPU 1; feeding that index into CUDA would land on the CPU fallback instead. -If you need a second NVIDIA GPU, either set `CUDA_VISIBLE_DEVICES` before starting Vocalinux or install a Vulkan-built pywhispercpp so the Advanced GPU picker applies. +If you need a second NVIDIA GPU, either set `CUDA_VISIBLE_DEVICES` before starting Vocalinux or install a Vulkan-built pywhispercpp so the Performance GPU picker applies. -To check which backend is being used, look for these log messages when starting Vocalinux: +`install.sh` rebuilds pywhispercpp with Vulkan or CUDA when it can. Prefer that launcher (or the wrapper it installs) so `LD_LIBRARY_PATH` includes the GPU libs. Raw `venv/bin` binaries can come up CPU-only. + +AppImages built from this tree compile pywhispercpp with Vulkan and use the host Vulkan driver at runtime. Older AppImages shipped the CPU-only wheel; if you see the `lacks GPU libraries` line, download a newer AppImage or use `install.sh`. + +Hybrid / PRIME / Optimus laptops: picking NVIDIA in Settings is enough when pywhispercpp is Vulkan-built. `prime-run` is not required for Vulkan, and it cannot fix a CPU-only wheel. Install `vulkan-tools` so `vulkaninfo` can enumerate devices; without it, whisper.cpp may default to GPU 0 (often the iGPU). + +To check which backend is being used, look for these log messages when starting Vocalinux (`vocalinux --debug`): ``` [INFO] whisper.cpp using Vulkan GPU backend: AMD Radeon RX 6800 -[INFO] whisper.cpp configured with n_threads=16 +[INFO] Using Vulkan GPU [0]: AMD Radeon RX 6800 +[INFO] whisper.cpp configured with n_threads=4 (GPU backend: vulkan) ``` ### Auto-pause and model keep-alive diff --git a/packaging/appimage/build.sh b/packaging/appimage/build.sh index d7dbbee..2db7d1e 100755 --- a/packaging/appimage/build.sh +++ b/packaging/appimage/build.sh @@ -15,8 +15,13 @@ # bundled, same runtime prerequisite as the PyPI install path documented # in docs/INSTALL.md. Add bundling if users hit missing-binary complaints. # -# GPU note: the pip pywhispercpp wheel is CPU-only. Vulkan/CUDA rebuilds -# stay on install.sh for now; the AppImage logs when GPU libs are absent. +# GPU: pip wheels of pywhispercpp are CPU-only. This script rebuilds +# pywhispercpp from source with GGML_VULKAN=1 when Vulkan headers and a +# shader compiler are on the build host. The AppImage uses the host Vulkan +# loader/ICDs at runtime (do not bundle NVIDIA/AMD driver ICDs). +# Set VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1 in CI so a failed rebuild fails +# the job instead of shipping a CPU-only image. Set +# VOCALINUX_APPIMAGE_SKIP_VULKAN=1 to force the CPU wheel. set -euo pipefail WHEEL="$1" @@ -176,6 +181,90 @@ copy_gi_runtime_libs() { done } +has_vulkan_build_deps() { + if [ -f /usr/include/vulkan/vulkan.h ] || pkg-config --exists vulkan 2>/dev/null; then + : + else + return 1 + fi + command -v cmake >/dev/null 2>&1 || return 1 + command -v g++ >/dev/null 2>&1 || command -v clang++ >/dev/null 2>&1 || return 1 + command -v glslc >/dev/null 2>&1 || command -v glslangValidator >/dev/null 2>&1 || return 1 + return 0 +} + +remove_appdir_pywhispercpp() { + find "$APPDIR/usr" -depth \( \ + -name 'pywhispercpp' -o \ + -name 'pywhispercpp.libs' -o \ + -name 'pywhispercpp-*.dist-info' -o \ + -name '_pywhispercpp*' \ + \) -exec rm -rf {} + 2>/dev/null || true +} + +copy_whisper_native_libs_to_usr_lib() { + mkdir -p "$APPDIR/usr/lib" + local lib + while IFS= read -r lib; do + [ -n "$lib" ] || continue + cp -aL "$lib" "$APPDIR/usr/lib/" 2>/dev/null || cp -a "$lib" "$APPDIR/usr/lib/" + echo " staged $(basename "$lib") -> usr/lib" + done < <(find "$APPDIR/usr" \( -name 'libggml*.so*' -o -name 'libwhisper.so*' \) ! -path '*/usr/lib/*' 2>/dev/null || true) +} + +rebuild_pywhispercpp_vulkan() { + local require_vulkan="${VOCALINUX_APPIMAGE_REQUIRE_VULKAN:-0}" + if [ "${VOCALINUX_APPIMAGE_SKIP_VULKAN:-0}" = "1" ]; then + echo "== Skipping pywhispercpp Vulkan rebuild (VOCALINUX_APPIMAGE_SKIP_VULKAN=1) ==" + return 0 + fi + + if ! has_vulkan_build_deps; then + echo "Vulkan build deps missing (libvulkan-dev, cmake, g++, glslc/glslangValidator)." >&2 + if [ "$require_vulkan" = "1" ]; then + echo "VOCALINUX_APPIMAGE_REQUIRE_VULKAN=1: refusing to ship a CPU-only AppImage." >&2 + exit 1 + fi + echo "Warning: AppImage will use the CPU-only pywhispercpp wheel." >&2 + return 0 + fi + + echo "== Rebuilding pywhispercpp with Vulkan ==" + remove_appdir_pywhispercpp + local pip_log="$WORKDIR/pywhispercpp-vulkan.log" + export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(nproc)}" + # Hosts that default cc/c++ to clang (this Cloud Agent image) fail the + # CMAKE C++ compiler test: clang does not search gcc's libstdc++ path. + if ! CC="${CC:-gcc}" CXX="${CXX:-g++}" \ + GGML_VULKAN=1 \ + CMAKE_ARGS='-DCMAKE_INSTALL_RPATH=$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON' \ + "$PYTHON" -m pip install --verbose --no-cache-dir --force-reinstall \ + --no-binary pywhispercpp --ignore-installed --prefix "$APPDIR/usr" \ + --log "$pip_log" pywhispercpp; then + echo "pywhispercpp Vulkan rebuild failed. Last 80 log lines:" >&2 + tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true + if [ "$require_vulkan" = "1" ]; then + exit 1 + fi + echo "Warning: continuing with the CPU-only pywhispercpp wheel." >&2 + return 0 + fi + + local vk_lib + vk_lib="$(find "$APPDIR/usr" -name 'libggml-vulkan.so*' 2>/dev/null | head -1 || true)" + if [ -z "$vk_lib" ]; then + echo "Vulkan rebuild did not produce libggml-vulkan.so." >&2 + if [ "$require_vulkan" = "1" ]; then + tail -n 80 "$pip_log" 2>/dev/null | sed 's/^/ /' >&2 || true + exit 1 + fi + echo "Warning: AppImage will use CPU-only pywhispercpp." >&2 + return 0 + fi + echo " found $vk_lib" + copy_whisper_native_libs_to_usr_lib +} + echo "== Copying GObject-Introspection typelibs (not handled by linuxdeploy-plugin-gtk) ==" copy_typelibs "$APPDIR/usr/lib/girepository-1.0" 0 @@ -198,6 +287,8 @@ APPRUN ln -sfn "python${PY_VER}" "$APPDIR/usr/lib/python3" chmod +x "$APPDIR/AppRun" +rebuild_pywhispercpp_vulkan + echo "== Running linuxdeploy (resolves the shared-library closure + GTK theming) ==" export DEPLOY_GTK_VERSION=3 "$TOOLDIR/linuxdeploy" --appdir "$APPDIR" \ diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 946c933..11edca5 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -899,6 +899,22 @@ def _get_system_model_paths() -> list: SYSTEM_MODELS_DIRS = _get_system_model_paths() +def detect_pywhispercpp_gpu_backend() -> str: + """Detect whether pywhispercpp's native library actually has GPU support.""" + _preload_pywhispercpp_shared_libraries() + for library_dir in _find_pywhispercpp_shared_library_dirs(): + root = Path(library_dir) + for pattern in ("libggml-vulkan*.so*", "libggml-cuda*.so*"): + matches = list(root.glob(pattern)) + if matches: + lib_name = matches[0].name.lower() + if "vulkan" in lib_name: + return "vulkan" + if "cuda" in lib_name: + return "cuda" + return "cpu" + + class SpeechRecognitionManager: """ Manager class for speech recognition engines. @@ -1225,7 +1241,7 @@ class SpeechRecognitionManager: """Initialize the whisper.cpp speech recognition engine.""" try: _preload_pywhispercpp_shared_libraries() - from pywhispercpp.model import Model # noqa: F401 — used in _load_whispercpp_model + from pywhispercpp.model import Model # noqa: F401 — fail fast if missing # Validate model size for whisper.cpp valid_models = list(WHISPERCPP_MODEL_INFO.keys()) @@ -1374,18 +1390,7 @@ class SpeechRecognitionManager: def _detect_pywhispercpp_gpu_backend(self) -> str: """Detect whether pywhispercpp's native library actually has GPU support.""" - _preload_pywhispercpp_shared_libraries() - for library_dir in _find_pywhispercpp_shared_library_dirs(): - root = Path(library_dir) - for pattern in ("libggml-vulkan*.so*", "libggml-cuda*.so*"): - matches = list(root.glob(pattern)) - if matches: - lib_name = matches[0].name.lower() - if "vulkan" in lib_name: - return "vulkan" - if "cuda" in lib_name: - return "cuda" - return "cpu" + return detect_pywhispercpp_gpu_backend() def _load_whispercpp_model(self, model_path: str): """Load the whisper.cpp model file and configure the compute backend. @@ -1399,27 +1404,23 @@ class SpeechRecognitionManager: import multiprocessing import time - from pywhispercpp.model import Model - from ..utils.whispercpp_model_info import ( ComputeBackend, _prefer_discrete_vulkan_device, detect_compute_backend, + detect_cpu_info, detect_vulkan_devices, get_backend_display_name, ) - # Detect and log compute backend - backend, backend_info = detect_compute_backend() - logger.info(f"whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") - logger.info( - f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}" - ) - + host_backend, host_info = detect_compute_backend() actual_gpu_backend = self._detect_pywhispercpp_gpu_backend() + logger.info("whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") - # Select GPU device for pywhispercpp context_params. selected_gpu_device = 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 @@ -1435,27 +1436,66 @@ class SpeechRecognitionManager: 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: - devices = detect_vulkan_devices() 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. " + "Inference will use the CPU. The Vulkan GPU picker in Settings cannot " + "accelerate a CPU-only wheel or AppImage. Install via install.sh " + "(rebuilds pywhispercpp with Vulkan/CUDA) or use an AppImage built " + "with GPU support.", + get_backend_display_name(host_backend), + host_info, + ) + + if 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.", + host_backend, + actual_gpu_backend, + actual_gpu_backend, + ) + + logger.info( + f"whisper.cpp using {get_backend_display_name(runtime_backend)} backend: {runtime_info}" + ) - # Log hardware summary import psutil total_ram_gb = psutil.virtual_memory().total // (1024**3) - logger.info(f"whisper.cpp hardware: {backend} | {backend_info} | RAM: {total_ram_gb}GB") + logger.info( + f"whisper.cpp hardware: {runtime_backend} | {runtime_info} | RAM: {total_ram_gb}GB" + ) - # Validate model file exists and get size if os.path.exists(model_path): model_size_mb = os.path.getsize(model_path) / (1024 * 1024) logger.info(f"whisper.cpp model file: {model_path} ({model_size_mb:.1f} MB)") @@ -1475,11 +1515,10 @@ class SpeechRecognitionManager: n_threads = max(1, min(multiprocessing.cpu_count() - 2, 8)) load_start_time = time.time() - loaded_backend = backend + loaded_backend = runtime_backend model_kwargs = self._build_whispercpp_model_kwargs(n_threads) - # Attempt to load model; filter unsupported params and fall back to CPU if needed try: self.model = self._load_model_with_compatible_params( model_path, model_kwargs, gpu_device=selected_gpu_device diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 2dd92ea..b524ce5 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -3598,6 +3598,20 @@ class SettingsDialog(Gtk.Dialog): self.release_notes_group.show() return False + def _gpu_acceleration_subtitle(self) -> str: + """Describe whether the bundled pywhispercpp can use the GPU picker.""" + try: + from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend + + bundled = detect_pywhispercpp_gpu_backend() + except (ImportError, OSError, AttributeError): + return "Device used by the whisper.cpp engine" + if bundled == "cpu": + return "This install has no GPU libraries; whisper.cpp will run on the CPU" + if bundled == "cuda": + return "CUDA build uses NVIDIA device 0; this picker applies to Vulkan builds" + return "Device used by the whisper.cpp Vulkan engine" + def _build_gpu_section(self): """Build the GPU device group on the Performance page. @@ -3608,13 +3622,14 @@ class SettingsDialog(Gtk.Dialog): self.gpu_device_combo = Gtk.ComboBoxText() self.gpu_device_combo.set_size_request(_CONTROL_WIDTH, -1) self.gpu_device_combo.set_tooltip_text( - "Select which GPU to use for whisper.cpp Vulkan acceleration" + "Select which GPU to use for whisper.cpp Vulkan acceleration. " + "Has no effect when pywhispercpp was built without GPU libraries." ) _prevent_scroll_on_hover(self.gpu_device_combo) self._populate_gpu_devices() gpu_row = PreferenceRow( title="Vulkan GPU", - subtitle="Device used by the whisper.cpp engine", + subtitle=self._gpu_acceleration_subtitle(), widget=self.gpu_device_combo, keywords=("graphics", "hardware", "acceleration", "device"), ) diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index e26ca0f..36db7aa 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -126,6 +126,16 @@ class ComputeBackend: CPU = "cpu" +# Match install.sh: these are not usable whisper.cpp GPUs for auto-select. +_SOFTWARE_VULKAN_NAME_MARKERS = ( + "llvmpipe", + "swiftshader", + "lavapipe", + "zink", + "virtio", + "venus", +) + # Real vulkaninfo --summary headers look like "GPU0:"; some older/alternate # tools print "GPU id = 0". Accept both so hybrid detection does not silently # fall through to CUDA when Vulkan is present. @@ -143,6 +153,38 @@ def _parse_vulkaninfo_gpu_header(line: str) -> Optional[int]: return int(match.group(1) or match.group(2)) +def _is_software_vulkan_name(name: Optional[str]) -> bool: + """Return True when a Vulkan device name looks like a software renderer.""" + if not name: + return False + lowered = name.lower() + return any(marker in lowered for marker in _SOFTWARE_VULKAN_NAME_MARKERS) + + +def _classify_vulkan_device_type(type_val: str, name: Optional[str]) -> str: + """Map vulkaninfo deviceType/name to discrete, integrated, software, or other.""" + if _is_software_vulkan_name(name) or "CPU" in type_val.upper(): + return "software" + upper = type_val.upper() + if "DISCRETE" in upper: + return "discrete" + if "INTEGRATED" in upper: + return "integrated" + return "other" + + +def _is_software_vulkan_device(device: dict) -> bool: + """Return True when a parsed Vulkan device should not be auto-selected.""" + return device.get("device_type") == "software" or _is_software_vulkan_name(device.get("name")) + + +def _hardware_vulkan_devices(devices: Optional[list[dict]] = None) -> list[dict]: + """Return Vulkan devices that are not software renderers.""" + if devices is None: + devices = detect_vulkan_devices() + return [device for device in devices if not _is_software_vulkan_device(device)] + + def _append_vulkan_device( devices: list[dict], current_index: Optional[int], @@ -152,70 +194,85 @@ def _append_vulkan_device( """Append a parsed Vulkan device when index and name are both known.""" if current_index is None or current_name is None: return + device_type = current_type + if _is_software_vulkan_name(current_name): + device_type = "software" devices.append( { "index": current_index, "name": current_name, - "device_type": current_type, + "device_type": device_type, } ) +def _run_vulkaninfo_stdout() -> str: + """Return vulkaninfo output, falling back when --summary is missing or empty.""" + commands = (["vulkaninfo", "--summary"], ["vulkaninfo"]) + for args in commands: + try: + result = subprocess.run( + args, + capture_output=True, + text=True, + timeout=8, + ) + except FileNotFoundError: + return "" + except (subprocess.TimeoutExpired, OSError) as exc: + logger.debug(f"vulkaninfo {args} failed: {exc}") + continue + + stdout = result.stdout or "" + if "deviceName" in stdout or _VULKANINFO_GPU_HEADER_RE.search(stdout): + return stdout + if result.returncode == 0 and stdout.strip(): + return stdout + return "" + + @lru_cache(maxsize=1) def detect_vulkan_devices() -> list[dict]: """Enumerate all Vulkan-capable GPU devices. Returns: List of dicts with keys: index (int), name (str), - device_type (str: "discrete" or "integrated" or "other"). + device_type (str: "discrete", "integrated", "software", or "other"). """ devices: list[dict] = [] - try: - result = subprocess.run( - ["vulkaninfo", "--summary"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - return devices + stdout = _run_vulkaninfo_stdout() + if not stdout: + return devices - current_index = None - current_name = None - current_type = "other" + current_index = None + current_name = None + current_type = "other" - for line in result.stdout.split("\n"): - stripped = line.strip() - header_index = _parse_vulkaninfo_gpu_header(stripped) + for line in stdout.split("\n"): + stripped = line.strip() + header_index = _parse_vulkaninfo_gpu_header(stripped) - if header_index is not None: - _append_vulkan_device(devices, current_index, current_name, current_type) - current_index = header_index - current_name = None - current_type = "other" - continue + if header_index is not None: + _append_vulkan_device(devices, current_index, current_name, current_type) + current_index = header_index + current_name = None + current_type = "other" + continue - if "deviceName" in stripped and "=" in stripped: - current_name = stripped.split("=", 1)[-1].strip() + if "deviceName" in stripped and "=" in stripped: + current_name = stripped.split("=", 1)[-1].strip() - if "deviceType" in stripped and "=" in stripped: - type_val = stripped.split("=", 1)[-1].strip().upper() - if "DISCRETE" in type_val: - current_type = "discrete" - elif "INTEGRATED" in type_val: - current_type = "integrated" - - _append_vulkan_device(devices, current_index, current_name, current_type) - - except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: - logger.debug(f"Vulkan device enumeration failed: {e}") + if "deviceType" in stripped and "=" in stripped: + type_val = stripped.split("=", 1)[-1].strip() + current_type = _classify_vulkan_device_type(type_val, current_name) + _append_vulkan_device(devices, current_index, current_name, current_type) return devices def _prefer_discrete_vulkan_device() -> Optional[int]: - """Return the index of the preferred Vulkan GPU (discrete if available).""" - devices = detect_vulkan_devices() + """Return the index of the preferred hardware Vulkan GPU (discrete if available).""" + devices = _hardware_vulkan_devices() for device in devices: if device["device_type"] == "discrete": return device["index"] @@ -244,9 +301,10 @@ def detect_vulkan_support() -> tuple[bool, Optional[str]]: Tuple of (is_available, device_name) """ devices = detect_vulkan_devices() - if devices: + hardware = _hardware_vulkan_devices(devices) + if hardware: preferred_idx = _prefer_discrete_vulkan_device() - device_name = _vulkan_device_name_by_index(devices, preferred_idx) + device_name = _vulkan_device_name_by_index(hardware, preferred_idx) logger.info(f"Vulkan support detected: {device_name}") return True, device_name diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py index 848ebc0..b93f8ff 100644 --- a/tests/test_appimage_packaging.py +++ b/tests/test_appimage_packaging.py @@ -56,3 +56,14 @@ def test_appimage_build_smokes_gi_without_host_typelibs(): text = BUILD_SH.read_text() assert "smoke_gi_imports" in text assert "unshare --user --mount" in text + + +def test_appimage_build_rebuilds_pywhispercpp_with_vulkan(): + text = BUILD_SH.read_text() + assert "GGML_VULKAN=1" in text + assert "libggml-vulkan" in text + assert "VOCALINUX_APPIMAGE_REQUIRE_VULKAN" in text + assert "VOCALINUX_APPIMAGE_SKIP_VULKAN" in text + assert "rebuild_pywhispercpp_vulkan" in text + assert 'CC="${CC:-gcc}"' in text + assert 'CXX="${CXX:-g++}"' in text diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index c2c526c..dd3736f 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -1105,6 +1105,76 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): "'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) @@ -1488,6 +1558,69 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): } assert "context_params" not in self.AttributeErrorContextParamsModel.calls[1][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) @@ -1530,11 +1663,16 @@ class TestWhispercppGpuDeviceSelection(unittest.TestCase): "get_backend_display_name", return_value="CPU", ): - manager._init_whispercpp() - assert ( - "context_params" - not in mock_pywhispercpp.Model.call_args.kwargs - ) + 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 TestTranscription(unittest.TestCase): diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 9501edb..49b69eb 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -984,6 +984,7 @@ class TestSettingsNavigation(unittest.TestCase): self.assertIn("def _build_gpu_section(self):", self.source_code) gpu_body = self.source_code.split("def _build_gpu_section")[1].split("\n def ")[0] self.assertIn("self.power_tab.pack_start(gpu_group", gpu_body) + self.assertIn("_gpu_acceleration_subtitle", gpu_body) advanced_body = self.source_code.split("def _build_advanced_section")[1].split( "\n def " )[0] diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index 721f8e6..bcc1c3a 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -323,6 +323,48 @@ class TestDetectVulkanDevices(unittest.TestCase): devices = detect_vulkan_devices() self.assertEqual(devices, []) + def test_detect_vulkan_devices_falls_back_without_summary(self): + """Older vulkaninfo without --summary still enumerates GPUs.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA RTX 2060\n" + ) + + def run_side_effect(cmd, **kwargs): + if cmd == ["vulkaninfo", "--summary"]: + return MagicMock(returncode=1, stdout="") + if cmd == ["vulkaninfo"]: + return MagicMock(returncode=0, stdout=vulkaninfo_output) + return MagicMock(returncode=1, stdout="") + + with patch("subprocess.run", side_effect=run_side_effect): + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0]["name"], "NVIDIA RTX 2060") + self.assertEqual(devices[0]["device_type"], "discrete") + + def test_detect_vulkan_devices_marks_llvmpipe_software(self): + """Software renderers are classified separately from hardware GPUs.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" + "\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\n" + "GPU1:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA GeForce RTX 2060\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import detect_vulkan_devices + + devices = detect_vulkan_devices() + self.assertEqual(devices[0]["device_type"], "software") + self.assertEqual(devices[1]["device_type"], "discrete") + class TestPreferDiscreteVulkanDevice(unittest.TestCase): """Test cases for _prefer_discrete_vulkan_device function.""" @@ -376,6 +418,53 @@ class TestPreferDiscreteVulkanDevice(unittest.TestCase): preferred_idx = _prefer_discrete_vulkan_device() self.assertIsNone(preferred_idx) + def test_prefer_discrete_skips_software_renderer(self): + """Auto-select must not pick llvmpipe when a real GPU exists.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" + "\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\n" + "GPU1:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_DISCRETE_GPU\n" + "\tdeviceName = NVIDIA RTX 2060\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import _prefer_discrete_vulkan_device + + preferred_idx = _prefer_discrete_vulkan_device() + self.assertEqual(preferred_idx, 1) + + def test_prefer_discrete_ignores_software_only(self): + """Software-only Vulkan should not produce an auto-selected device.""" + vulkaninfo_output = ( + "GPU0:\n" + "\tdeviceType = PHYSICAL_DEVICE_TYPE_CPU\n" + "\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\n" + ) + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout=vulkaninfo_output) + + from vocalinux.utils.whispercpp_model_info import ( + ComputeBackend, + _prefer_discrete_vulkan_device, + detect_compute_backend, + detect_cuda_support, + detect_vulkan_support, + ) + + self.assertIsNone(_prefer_discrete_vulkan_device()) + detect_vulkan_support.cache_clear() + is_available, device_name = detect_vulkan_support() + self.assertFalse(is_available) + self.assertIsNone(device_name) + + detect_cuda_support.cache_clear() + detect_compute_backend.cache_clear() + backend, _info = detect_compute_backend() + self.assertNotEqual(backend, ComputeBackend.VULKAN) + def test_prefer_discrete_with_noncontiguous_gpu_ids(self): """Preferred index is the Vulkan GPU id, not the list position.""" vulkaninfo_output = (