fix(installer): repair pywhispercpp library loading (#433)

This commit is contained in:
Jatin K Malik
2026-05-21 21:37:04 -07:00
committed by GitHub
parent 9a397c994c
commit 722fc23403
3 changed files with 362 additions and 6 deletions
+192 -6
View File
@@ -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
@@ -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(
+53
View File
@@ -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."""