diff --git a/install.sh b/install.sh index 5712793..606985e 100755 --- a/install.sh +++ b/install.sh @@ -1898,9 +1898,87 @@ EOF chmod +x "$ACTIVATION_SCRIPT" print_info "Created activation script: $ACTIVATION_SCRIPT" +get_pywhispercpp_library_path() { + [ -x "$VENV_DIR/bin/python" ] || return 1 + "$VENV_DIR/bin/python" - <<'PY' 2>/dev/null +from pathlib import Path +import site +import sys +import sysconfig + +roots = [] +for attr in ("getsitepackages",): + get_paths = getattr(site, attr, None) + if get_paths is None: + continue + try: + roots.extend(get_paths()) + except Exception: + pass + +user_site = getattr(site, "getusersitepackages", lambda: None)() +if user_site: + roots.append(user_site) + +for key in ("platlib", "purelib"): + path = sysconfig.get_paths().get(key) + if path: + roots.append(path) + +roots.extend(path for path in sys.path if path) + +dirs = [] +seen = set() +for root in roots: + root_path = Path(root) + candidates = [ + root_path / "pywhispercpp.libs", + root_path / "pywhispercpp" / ".libs", + root_path / "pywhispercpp" / "lib", + root_path, + ] + for candidate in candidates: + try: + resolved = str(candidate.resolve()) + except OSError: + continue + if resolved in seen or not candidate.is_dir(): + continue + if any(candidate.glob("libwhisper*.so*")) or any(candidate.glob("libggml*.so*")): + seen.add(resolved) + dirs.append(resolved) + +print(":".join(dirs)) +PY +} + +with_pywhispercpp_library_path() { + local PYWHISPERCPP_LIBRARY_PATH + PYWHISPERCPP_LIBRARY_PATH=$(get_pywhispercpp_library_path || true) + + if [ -n "$PYWHISPERCPP_LIBRARY_PATH" ]; then + LD_LIBRARY_PATH="$PYWHISPERCPP_LIBRARY_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$@" + else + "$@" + fi +} + +get_pywhispercpp_cmake_args() { + printf '%s\n' '-DCMAKE_INSTALL_RPATH=$ORIGIN -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON' +} + +install_cpu_pywhispercpp() { + local PIP_LOG_FILE="$1" + local PYWHISPERCPP_CMAKE_ARGS + PYWHISPERCPP_CMAKE_ARGS=$(get_pywhispercpp_cmake_args) + + CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" \ + pip install --force-reinstall --no-cache-dir pywhispercpp --log "$PIP_LOG_FILE" +} + is_pywhispercpp_installed() { [ -x "$VENV_DIR/bin/python" ] || return 1 - "$VENV_DIR/bin/python" -c "from pywhispercpp.model import Model" >/dev/null 2>&1 + with_pywhispercpp_library_path "$VENV_DIR/bin/python" -c "from pywhispercpp.model import Model" >/dev/null 2>&1 } get_pywhispercpp_version() { @@ -2030,6 +2108,8 @@ install_python_package() { local GPU_BACKEND="CPU" local GPU_INSTALL_SUCCESS=false local SKIP_WHISPERCPP_INSTALL=false + local PYWHISPERCPP_CMAKE_ARGS + PYWHISPERCPP_CMAKE_ARGS=$(get_pywhispercpp_cmake_args) if [[ "$WHISPERCPP_ALREADY_INSTALLED" == "true" ]]; then GPU_BACKEND="existing" @@ -2053,7 +2133,7 @@ install_python_package() { print_info " Installing pywhispercpp with Vulkan support..." GPU_BACKEND="Vulkan" print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." - if GGML_VULKAN=1 pip install --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then + if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" GGML_VULKAN=1 pip install --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then GPU_INSTALL_SUCCESS=true else print_warning "Vulkan build failed - checking for NVIDIA GPU to try CUDA..." @@ -2066,7 +2146,7 @@ install_python_package() { print_info " Installing pywhispercpp with CUDA support..." GPU_BACKEND="CUDA" print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." - if GGML_CUDA=1 pip install --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then + if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" GGML_CUDA=1 pip install --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then GPU_INSTALL_SUCCESS=true fi fi @@ -2093,7 +2173,7 @@ install_python_package() { fi GPU_BACKEND="CPU" print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." - pip install pywhispercpp --log "$PIP_LOG_FILE" || { + install_cpu_pywhispercpp "$PIP_LOG_FILE" || { print_error "Failed to install pywhispercpp" return 1 } @@ -2104,6 +2184,60 @@ install_python_package() { else print_success "pywhispercpp installed with $GPU_BACKEND backend" fi + + if ! is_pywhispercpp_installed; then + print_warning "pywhispercpp installed but import verification failed." + print_warning "This can happen when libwhisper.so is installed beside the Python extension without a runtime library path." + + if [[ "$SKIP_WHISPERCPP_INSTALL" != "true" ]]; then + print_warning "Trying RPATH-aware CPU pywhispercpp fallback..." + GPU_BACKEND="CPU" + if install_cpu_pywhispercpp "$PIP_LOG_FILE"; then + print_success "CPU pywhispercpp fallback installed" + else + print_warning "CPU pywhispercpp fallback installation failed" + fi + fi + fi + + if ! is_pywhispercpp_installed; then + print_warning "pywhispercpp is still unavailable; setting VOSK as the default engine so Vocalinux can start." + print_warning "You can switch back to whisper.cpp from Settings after reinstalling pywhispercpp." + SELECTED_ENGINE="vosk" + + local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json" + if [ ! -f "$FALLBACK_VOSK_CONFIG" ]; then + mkdir -p "$CONFIG_DIR" + cat > "$FALLBACK_VOSK_CONFIG" << 'FALLBACK_VOSK_CONFIG' +{ + "speech_recognition": { + "engine": "vosk", + "model_size": "small", + "vosk_model_size": "small", + "whisper_model_size": "tiny", + "whisper_cpp_model_size": "tiny", + "vad_sensitivity": 3, + "silence_timeout": 2.0 + }, + "audio": { + "device_index": null, + "device_name": null + }, + "shortcuts": { + "toggle_recognition": "ctrl+ctrl" + }, + "ui": { + "start_minimized": false, + "show_notifications": true + }, + "advanced": { + "debug_logging": false, + "wayland_mode": false + } +} +FALLBACK_VOSK_CONFIG + fi + fi echo "" ;; @@ -2181,7 +2315,7 @@ WHISPER_CONFIG print_info "" # Fall back to whisper.cpp installation - pip install pywhispercpp --log "$PIP_LOG_FILE" || { + install_cpu_pywhispercpp "$PIP_LOG_FILE" || { print_error "Failed to install pywhispercpp fallback" print_error "Please try installing manually: pip install pywhispercpp" return 1 @@ -2284,6 +2418,32 @@ VOSK_CONFIG # Wrapper script for Vocalinux that sets required environment variables # and applies the 'input' group for keyboard shortcuts on Wayland export GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED +PYWHISPERCPP_LIBRARY_PATH="" +PY_SITE_PATHS=\$("$VENV_DIR/bin/python" - <<'PY' 2>/dev/null +import sysconfig + +paths = [] +for key in ("platlib", "purelib"): + path = sysconfig.get_paths().get(key) + if path and path not in paths: + paths.append(path) +print(" ".join(paths)) +PY +) +for PY_SITE in \$PY_SITE_PATHS; do + for PY_LIB_DIR in "\$PY_SITE/pywhispercpp.libs" "\$PY_SITE/pywhispercpp/.libs" "\$PY_SITE/pywhispercpp/lib"; do + if [ -d "\$PY_LIB_DIR" ] && { ls "\$PY_LIB_DIR"/libwhisper*.so* >/dev/null 2>&1 || ls "\$PY_LIB_DIR"/libggml*.so* >/dev/null 2>&1; }; then + if [ -z "\$PYWHISPERCPP_LIBRARY_PATH" ]; then + PYWHISPERCPP_LIBRARY_PATH="\$PY_LIB_DIR" + else + PYWHISPERCPP_LIBRARY_PATH="\$PYWHISPERCPP_LIBRARY_PATH:\$PY_LIB_DIR" + fi + fi + done +done +if [ -n "\$PYWHISPERCPP_LIBRARY_PATH" ]; then + export LD_LIBRARY_PATH="\$PYWHISPERCPP_LIBRARY_PATH\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}" +fi # Check if user is in input group but current session doesn't have it if grep -q "^input:.*\b\$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q '\binput\b'; then @@ -2302,6 +2462,32 @@ WRAPPER_EOF # Wrapper script for Vocalinux GUI that sets required environment variables # and applies the 'input' group for keyboard shortcuts on Wayland export GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED +PYWHISPERCPP_LIBRARY_PATH="" +PY_SITE_PATHS=\$("$VENV_DIR/bin/python" - <<'PY' 2>/dev/null +import sysconfig + +paths = [] +for key in ("platlib", "purelib"): + path = sysconfig.get_paths().get(key) + if path and path not in paths: + paths.append(path) +print(" ".join(paths)) +PY +) +for PY_SITE in \$PY_SITE_PATHS; do + for PY_LIB_DIR in "\$PY_SITE/pywhispercpp.libs" "\$PY_SITE/pywhispercpp/.libs" "\$PY_SITE/pywhispercpp/lib"; do + if [ -d "\$PY_LIB_DIR" ] && { ls "\$PY_LIB_DIR"/libwhisper*.so* >/dev/null 2>&1 || ls "\$PY_LIB_DIR"/libggml*.so* >/dev/null 2>&1; }; then + if [ -z "\$PYWHISPERCPP_LIBRARY_PATH" ]; then + PYWHISPERCPP_LIBRARY_PATH="\$PY_LIB_DIR" + else + PYWHISPERCPP_LIBRARY_PATH="\$PYWHISPERCPP_LIBRARY_PATH:\$PY_LIB_DIR" + fi + fi + done +done +if [ -n "\$PYWHISPERCPP_LIBRARY_PATH" ]; then + export LD_LIBRARY_PATH="\$PYWHISPERCPP_LIBRARY_PATH\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}" +fi # Check if user is in input group but current session doesn't have it if grep -q "^input:.*\b\$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q '\binput\b'; then @@ -2760,7 +2946,7 @@ if [ "$SKIP_MODELS" = "no" ]; then # Check which engines are installed and download appropriate models # Install whisper.cpp model (default engine) - if "$VENV_DIR/bin/python" -c "from pywhispercpp.model import Model" 2>/dev/null; then + if is_pywhispercpp_installed; then print_info "whisper.cpp is installed - downloading tiny model (default engine)..." install_whispercpp_model || print_warning "whisper.cpp model download failed - model will be downloaded on first run" fi diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 1a91f3c..0d0a7a9 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -6,6 +6,7 @@ currently supporting VOSK, Whisper, and whisper.cpp. """ import ctypes +import importlib.util import json import logging import os @@ -13,6 +14,7 @@ import queue import sys import threading import time +from pathlib import Path from typing import Callable, Optional from ..common_types import RecognitionState @@ -61,6 +63,119 @@ def _setup_alsa_error_handler(): # Set up ALSA error handler at module load time _alsa_handler = _setup_alsa_error_handler() +_PYWHISPERCPP_PRELOADED_LIBS: list[ctypes.CDLL] = [] + + +def _find_pywhispercpp_shared_library_dirs() -> list[str]: + """Find bundled pywhispercpp native library directories without importing it.""" + candidate_dirs: list[Path] = [] + + for module_name in ("_pywhispercpp", "pywhispercpp"): + try: + spec = importlib.util.find_spec(module_name) + except (ImportError, AttributeError, ValueError): + spec = None + if spec is None: + continue + + if spec.origin: + module_dir = Path(spec.origin).resolve().parent + candidate_dirs.extend( + [ + module_dir, + module_dir / ".libs", + module_dir / "lib", + module_dir / "pywhispercpp.libs", + module_dir.parent / "pywhispercpp.libs", + ] + ) + + if spec.submodule_search_locations: + for location in spec.submodule_search_locations: + package_dir = Path(location).resolve() + candidate_dirs.extend( + [ + package_dir, + package_dir / ".libs", + package_dir / "lib", + package_dir.parent / "pywhispercpp.libs", + ] + ) + + for path_entry in sys.path: + if not path_entry: + continue + path_root = Path(path_entry).resolve() + candidate_dirs.append(path_root / "pywhispercpp.libs") + + library_dirs: list[str] = [] + seen: set[str] = set() + for candidate_dir in candidate_dirs: + try: + resolved_dir = str(candidate_dir.resolve()) + except OSError: + continue + + if resolved_dir in seen or not candidate_dir.is_dir(): + continue + + has_native_lib = any(candidate_dir.glob("libwhisper*.so*")) or any( + candidate_dir.glob("libggml*.so*") + ) + if has_native_lib: + seen.add(resolved_dir) + library_dirs.append(resolved_dir) + + return library_dirs + + +def _preload_pywhispercpp_shared_libraries() -> None: + """Preload bundled pywhispercpp shared libraries for source-built installs. + + Some source builds place libwhisper/libggml next to the Python extension + without an RPATH. Preloading by absolute path lets the dynamic loader satisfy + the extension's libwhisper.so.1 dependency before importing pywhispercpp. + """ + if _PYWHISPERCPP_PRELOADED_LIBS: + return + + libraries: list[Path] = [] + for library_dir in _find_pywhispercpp_shared_library_dirs(): + root = Path(library_dir) + libraries.extend(sorted(root.glob("libggml*.so*"))) + libraries.extend(sorted(root.glob("libwhisper*.so*"))) + + if not libraries: + return + + pending = list(dict.fromkeys(libraries)) + loaded: list[ctypes.CDLL] = [] + last_errors: dict[str, OSError] = {} + mode = getattr(ctypes, "RTLD_GLOBAL", 0) + + # Native libs can depend on each other. Retry while progress is made so a + # dependency loaded earlier in the same directory can unlock later libraries. + while pending: + loaded_this_pass = False + for library_path in pending[:]: + try: + loaded.append(ctypes.CDLL(str(library_path), mode=mode)) + pending.remove(library_path) + loaded_this_pass = True + except OSError as e: + last_errors[str(library_path)] = e + + if not loaded_this_pass: + break + + _PYWHISPERCPP_PRELOADED_LIBS.extend(loaded) + + if pending: + logger.debug( + "Could not preload all pywhispercpp native libraries: %s", + {str(path): str(last_errors.get(str(path))) for path in pending}, + ) + def get_audio_input_devices() -> list: """ @@ -804,6 +919,7 @@ class SpeechRecognitionManager: def _init_whispercpp(self): """Initialize the whisper.cpp speech recognition engine.""" try: + _preload_pywhispercpp_shared_libraries() from pywhispercpp.model import Model # noqa: F401 — used in _load_whispercpp_model # Validate model size for whisper.cpp @@ -864,6 +980,7 @@ class SpeechRecognitionManager: def _get_supported_whispercpp_params(self) -> Optional[set[str]]: """Return params supported by the active pywhispercpp native binding.""" try: + _preload_pywhispercpp_shared_libraries() import _pywhispercpp as pw params = pw.whisper_full_default_params( diff --git a/tests/test_recognition_manager_helpers.py b/tests/test_recognition_manager_helpers.py index a1cad32..d6e2c84 100644 --- a/tests/test_recognition_manager_helpers.py +++ b/tests/test_recognition_manager_helpers.py @@ -3,6 +3,7 @@ import os import sys import unittest +from importlib.machinery import EXTENSION_SUFFIXES from unittest.mock import MagicMock, patch import pytest @@ -33,10 +34,13 @@ if "gi" not in sys.modules: if "gi.repository" not in sys.modules: sys.modules["gi.repository"] = MagicMock() +from vocalinux.speech_recognition import recognition_manager as rm from vocalinux.speech_recognition.recognition_manager import ( SpeechRecognitionManager, _filter_non_speech, + _find_pywhispercpp_shared_library_dirs, _get_system_model_paths, + _preload_pywhispercpp_shared_libraries, _show_notification, ) from vocalinux.speech_recognition.recognition_manager import ( # noqa: E402 @@ -140,6 +144,55 @@ class TestGetSystemModelPaths(unittest.TestCase): self.assertIsInstance(paths, list) +class TestPywhispercppLibraryHelpers: + """Test pywhispercpp native library discovery and preloading.""" + + def test_find_pywhispercpp_shared_library_dirs(self, tmp_path, monkeypatch): + site_packages = tmp_path / "site-packages" + package_dir = site_packages / "pywhispercpp" + libs_dir = site_packages / "pywhispercpp.libs" + package_dir.mkdir(parents=True) + libs_dir.mkdir() + (package_dir / "__init__.py").write_text("") + (site_packages / f"_pywhispercpp{EXTENSION_SUFFIXES[0]}").write_text("") + (libs_dir / "libwhisper.so.1").write_text("") + + monkeypatch.syspath_prepend(str(site_packages)) + sys.modules.pop("pywhispercpp", None) + sys.modules.pop("_pywhispercpp", None) + + library_dirs = _find_pywhispercpp_shared_library_dirs() + + assert str(libs_dir.resolve()) in library_dirs + + def test_preload_pywhispercpp_shared_libraries(self, tmp_path, monkeypatch): + libs_dir = tmp_path / "pywhispercpp.libs" + libs_dir.mkdir() + ggml_lib = libs_dir / "libggml.so" + whisper_lib = libs_dir / "libwhisper.so.1" + ggml_lib.write_text("") + whisper_lib.write_text("") + + loaded_handles = [] + + def fake_cdll(path, mode=0): + handle = MagicMock(path=path, mode=mode) + loaded_handles.append(handle) + return handle + + monkeypatch.setattr(rm, "_PYWHISPERCPP_PRELOADED_LIBS", []) + monkeypatch.setattr(rm, "_find_pywhispercpp_shared_library_dirs", lambda: [str(libs_dir)]) + monkeypatch.setattr(rm.ctypes, "CDLL", fake_cdll) + + _preload_pywhispercpp_shared_libraries() + + assert [handle.path for handle in loaded_handles] == [ + str(ggml_lib), + str(whisper_lib), + ] + assert rm._PYWHISPERCPP_PRELOADED_LIBS == loaded_handles + + class TestTestAudioInput(unittest.TestCase): """Test test_audio_input function."""