diff --git a/.github/workflows/distro-test-matrix.yml b/.github/workflows/distro-test-matrix.yml index 6e8c6f5..3e14508 100644 --- a/.github/workflows/distro-test-matrix.yml +++ b/.github/workflows/distro-test-matrix.yml @@ -35,7 +35,13 @@ jobs: apt-get update apt-get install -y bash curl python3 git elif command -v dnf >/dev/null 2>&1; then - dnf install -y bash curl python3 git + # Fedora needs cache update first in container environment + dnf clean all || true + dnf makecache || true + dnf install -y bash curl python3 git || { + echo "⚠ dnf install failed, trying with --nogpgcheck..." + dnf install -y --nogpgcheck bash curl python3 git + } fi # ============================================================================ diff --git a/install.sh b/install.sh index a501d34..49ceb24 100755 --- a/install.sh +++ b/install.sh @@ -31,14 +31,20 @@ WITH_WHISPER="no" WHISPER_CPU="no" NO_WHISPER_EXPLICIT="no" NON_INTERACTIVE="no" -INTERACTIVE_MODE="no" +INTERACTIVE_MODE="yes" # Default to interactive mode +AUTO_MODE="no" HAS_NVIDIA_GPU="unknown" GPU_NAME="" GPU_MEMORY="" +HAS_VULKAN="no" +VULKAN_DEVICE="" # Detect if running non-interactively (e.g., via curl | bash) +# Interactive mode is now default, so only auto-detect non-interactive for curl pipes if [ ! -t 0 ]; then - NON_INTERACTIVE="yes" + # When piped via curl, we'll still try to be interactive if possible + # User can force non-interactive with --auto + INTERACTIVE_MODE="ask" fi while [[ $# -gt 0 ]]; do @@ -59,19 +65,8 @@ while [[ $# -gt 0 ]]; do SKIP_MODELS="yes" shift ;; - --with-whisper) - WITH_WHISPER="yes" - shift - ;; - --whisper-cpu) - WITH_WHISPER="yes" - WHISPER_CPU="yes" - shift - ;; - --no-whisper) - WITH_WHISPER="no" - NO_WHISPER_EXPLICIT="yes" - NON_INTERACTIVE="yes" # Skip the prompt + --engine=*) + SELECTED_ENGINE="${1#*=}" shift ;; --interactive|-i) @@ -82,25 +77,38 @@ while [[ $# -gt 0 ]]; do INSTALL_TAG="${1#*=}" shift ;; - -y|--yes) + --auto) + AUTO_MODE="yes" + INTERACTIVE_MODE="no" NON_INTERACTIVE="yes" shift ;; --help) echo "Vocalinux Installer" + echo "" echo "Usage: $0 [options]" + echo "" + echo "Installation Modes:" + echo " (no flags) Interactive mode - guided setup with recommendations" + echo " --auto Automatic mode - install with defaults (whisper.cpp)" + echo " --auto --engine=whisper Auto mode with specific engine" + echo "" echo "Options:" - echo " --interactive, -i Guided interactive installation with recommendations" + echo " --interactive, -i Force interactive mode (default)" + echo " --auto Non-interactive automatic installation" + echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk" echo " --dev Install in development mode with all dev dependencies" echo " --test Run tests after installation" - echo " --venv-dir=PATH Specify custom virtual environment directory (default: venv)" + echo " --venv-dir=PATH Specify custom virtual environment directory" echo " --skip-models Skip downloading speech models during installation" - echo " --with-whisper Install Whisper AI with GPU/CUDA (included by default)" - echo " --whisper-cpu Install Whisper with CPU-only PyTorch (smaller download, works on low-RAM)" - echo " --no-whisper VOSK-only install (skips Whisper entirely, uses VOSK as default)" echo " --tag=TAG Install specific release tag (default: v0.4.1-alpha)" - echo " -y, --yes Non-interactive mode (accept defaults)" echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # Interactive mode (recommended)" + echo " $0 --auto # Auto-install with whisper.cpp" + echo " $0 --auto --engine=vosk # Auto-install VOSK only" + echo " $0 --dev --test # Dev mode with tests" exit 0 ;; *) @@ -255,6 +263,48 @@ detect_nvidia_gpu() { fi } +# Detect Vulkan support for whisper.cpp +detect_vulkan() { + # Check for vulkaninfo command + if command -v vulkaninfo >/dev/null 2>&1; then + local vulkan_output=$(vulkaninfo --summary 2>/dev/null | head -20) + if [ -n "$vulkan_output" ]; then + HAS_VULKAN="yes" + # Try to extract GPU name + VULKAN_DEVICE=$(echo "$vulkan_output" | grep -i "deviceName" | head -1 | cut -d'=' -f2 | xargs) + if [ -z "$VULKAN_DEVICE" ]; then + VULKAN_DEVICE="Vulkan-compatible GPU" + fi + return 0 + fi + fi + HAS_VULKAN="no" + return 1 +} + +# Detect hardware and recommend best engine +get_engine_recommendation() { + detect_nvidia_gpu || true + detect_vulkan || true + + # Get RAM info + local TOTAL_RAM_GB=$(free -g 2>/dev/null | awk '/^Mem:/{print $2}' || echo "0") + + if [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then + # NVIDIA GPU detected - whisper.cpp can use CUDA + echo "whisper_cpp:✓:NVIDIA GPU detected ($GPU_NAME) - Best performance with whisper.cpp" + elif [[ "$HAS_VULKAN" == "yes" ]]; then + # Non-NVIDIA GPU with Vulkan support + echo "whisper_cpp:✓:$VULKAN_DEVICE detected - Great performance with whisper.cpp Vulkan" + elif [ "$TOTAL_RAM_GB" -ge 8 ]; then + # No GPU but decent RAM + echo "whisper_cpp:✓:No GPU detected, but ${TOTAL_RAM_GB}GB RAM - whisper.cpp CPU mode" + else + # Low RAM, no GPU + echo "vosk:⚠:Low RAM (${TOTAL_RAM_GB}GB) and no GPU - VOSK recommended for best performance" + fi +} + # Detect GI_TYPELIB_PATH for cross-distro compatibility detect_typelib_path() { # Try pkg-config first (most reliable) @@ -318,86 +368,103 @@ EOF echo "Welcome! This guided installation will help you set up Vocalinux" echo "with the best options for your system." echo "" - echo "Both speech engines are 100% offline, local, and private." + echo "All speech engines are 100% offline, local, and private." echo "Your voice data never leaves your computer." echo "" # Step 1: Detect and display system info print_header "Step 1: Your System" echo "Detected: $DISTRO_NAME $DISTRO_VERSION" - - detect_nvidia_gpu || true - if [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then - echo "GPU: $GPU_NAME ($GPU_MEMORY)" - RECOMMENDED_ENGINE="whisper-gpu" - RECOMMENDED_LABEL="Whisper AI with GPU acceleration" - else - echo "GPU: None detected (or no NVIDIA drivers)" - RECOMMENDED_ENGINE="whisper-cpu" - RECOMMENDED_LABEL="Whisper AI (CPU-only)" - fi + + # Get hardware recommendation + local RECOMMENDATION=$(get_engine_recommendation) + local RECOMMENDED_ENGINE=$(echo "$RECOMMENDATION" | cut -d':' -f1) + local RECOMMENDED_ICON=$(echo "$RECOMMENDATION" | cut -d':' -f2) + local RECOMMENDED_REASON=$(echo "$RECOMMENDATION" | cut -d':' -f3-) + + echo "Hardware: $RECOMMENDED_REASON" echo "" - # Step 2: Choose installation type - print_header "Step 2: Installation Type" - echo "Choose what to install:" + # Step 2: Choose speech recognition engine + print_header "Step 2: Choose Speech Recognition Engine" echo "" - echo " 1. Full install (recommended)" - echo " • Whisper AI - highest accuracy, 99+ languages" - echo " • VOSK - fast fallback engine" - echo " • Can switch between engines in Settings" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 1. WHISPER.CPP ★ RECOMMENDED │" + echo " │ • Fastest, most accurate, works with any GPU │" + echo " │ • Supports NVIDIA (CUDA), AMD, Intel (Vulkan) │" + echo " │ • CPU-only mode available for older systems │" + echo " │ • Models: tiny (39MB) to large (1.5GB) │" + echo " │ • 99+ languages with auto-detection │" + echo " └─────────────────────────────────────────────────────────────┘" echo "" - echo " 2. Minimal install" - echo " • VOSK only - lightweight and fast" - echo " • Works on older/low-RAM systems" - echo " • ~40MB vs ~2GB+ for full install" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 2. WHISPER (OpenAI) │" + echo " │ • PyTorch-based, high accuracy │" + echo " │ • Only supports NVIDIA GPUs (CUDA) │" + echo " │ • Larger download (~2GB with CUDA) │" + echo " │ • Good for development/research │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + echo " ┌─────────────────────────────────────────────────────────────┐" + echo " │ 3. VOSK │" + echo " │ • Lightweight and fast │" + echo " │ • Works on older/low-RAM systems │" + echo " │ • ~40MB download │" + echo " │ • Good for basic dictation needs │" + echo " └─────────────────────────────────────────────────────────────┘" + echo "" + + # Show recommendation + case "$RECOMMENDED_ENGINE" in + whisper_cpp) + echo " → Recommendation: whisper.cpp (best performance for your hardware)" + DEFAULT_CHOICE="1" + ;; + vosk) + echo " → Recommendation: VOSK (lightweight option for your system)" + DEFAULT_CHOICE="3" + ;; + *) + echo " → Recommendation: whisper.cpp (best overall experience)" + DEFAULT_CHOICE="1" + ;; + esac echo "" - read -p "Choose installation type [1-2] (default: 1): " ENGINE_CHOICE - ENGINE_CHOICE=${ENGINE_CHOICE:-1} + read -p "Choose engine [1-3] (default: $DEFAULT_CHOICE): " ENGINE_CHOICE + ENGINE_CHOICE=${ENGINE_CHOICE:-$DEFAULT_CHOICE} - if [[ "$ENGINE_CHOICE" == "2" ]]; then - WITH_WHISPER="no" - NO_WHISPER_EXPLICIT="yes" - SELECTED_ENGINE="Minimal (VOSK only)" - else - WITH_WHISPER="yes" - SELECTED_ENGINE="Full (Whisper + VOSK)" + case "$ENGINE_CHOICE" in + 1) + SELECTED_ENGINE="whisper_cpp" + ENGINE_DISPLAY="Whisper.cpp (Recommended)" + ;; + 2) + SELECTED_ENGINE="whisper" + ENGINE_DISPLAY="Whisper (OpenAI)" + ;; + 3) + SELECTED_ENGINE="vosk" + ENGINE_DISPLAY="VOSK (Lightweight)" + ;; + *) + SELECTED_ENGINE="whisper_cpp" + ENGINE_DISPLAY="Whisper.cpp (Recommended)" + ;; + esac - # Step 3: Whisper variant (if Whisper selected) - print_header "Step 3: Whisper Installation Type" - echo "How would you like to install Whisper?" - echo "" - echo " 1. GPU version (~2.3GB) - For NVIDIA GPUs" - echo " 2. CPU-only (~200MB) - For any system" - echo "" - - if [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then - DEFAULT_WHISPER="1" - echo " -> Recommended: Option 1 (GPU detected)" - else - DEFAULT_WHISPER="2" - echo " -> Recommended: Option 2 (no GPU detected)" - fi - echo "" - - read -p "Choose installation type [1-2] (default: $DEFAULT_WHISPER): " WHISPER_CHOICE - WHISPER_CHOICE=${WHISPER_CHOICE:-$DEFAULT_WHISPER} - - if [[ "$WHISPER_CHOICE" == "2" ]]; then - WHISPER_CPU="yes" - SELECTED_VARIANT="Whisper AI (CPU-only)" - else - SELECTED_VARIANT="Whisper AI (GPU)" - fi - fi - - # Step 4: Download models now or later? - print_header "Step 4: Model Download" - echo "Speech recognition models need to be downloaded." + # Step 3: Model download preference + print_header "Step 3: Model Download" echo "" - echo " 1. Download now (recommended) - Faster first run" - echo " 2. Download later - Smaller install, models download on first use" + echo "Speech recognition models can be downloaded now or later." + echo "" + echo " 1. Download now (recommended)" + echo " • Faster first run - ready to use immediately" + echo " • Offline capable right after install" + echo "" + echo " 2. Download later" + echo " • Smaller initial install" + echo " • Models download automatically on first use" echo "" read -p "Download models now? [1-2] (default: 1): " MODELS_CHOICE @@ -405,21 +472,20 @@ EOF if [[ "$MODELS_CHOICE" == "2" ]]; then SKIP_MODELS="yes" + MODELS_DISPLAY="Download on first use" + else + MODELS_DISPLAY="Download now (recommended)" fi # Summary print_header "Installation Summary" - echo "Install type: $SELECTED_ENGINE" - if [[ "$WITH_WHISPER" == "yes" ]]; then - echo "Whisper variant: ${SELECTED_VARIANT:-GPU}" - fi - echo "Models: $([[ "$SKIP_MODELS" == "yes" ]] && echo "Download on first use" || echo "Download now")" echo "" - read -p "Press Enter to continue, or Ctrl+C to cancel..." + echo " Speech Engine: $ENGINE_DISPLAY" + echo " Models: $MODELS_DISPLAY" + echo " Install Location: ${INSTALL_DIR:-\$HOME/.local/share/vocalinux}" + echo "" + read -p "Press Enter to continue with installation, or Ctrl+C to cancel..." echo "" - - # Set non-interactive to skip the old prompt - NON_INTERACTIVE="yes" } # Detect distribution @@ -453,13 +519,35 @@ else fi fi -# Check if interactive mode is requested +# Handle installation mode selection +if [[ "$INTERACTIVE_MODE" == "ask" ]]; then + # Running via curl pipe but we have a terminal - ask user preference + echo "" + echo "Installation Mode:" + echo " 1. Interactive (recommended) - guided setup with recommendations" + echo " 2. Automatic - quick install with defaults (whisper.cpp)" + echo "" + read -p "Choose mode [1-2] (default: 1): " MODE_CHOICE + MODE_CHOICE=${MODE_CHOICE:-1} + + if [[ "$MODE_CHOICE" == "2" ]]; then + AUTO_MODE="yes" + INTERACTIVE_MODE="no" + NON_INTERACTIVE="yes" + else + INTERACTIVE_MODE="yes" + NON_INTERACTIVE="no" + fi + echo "" +fi + +# Run interactive installation if selected if [[ "$INTERACTIVE_MODE" == "yes" ]]; then # Check if we have a TTY (required for interactive mode) if [ ! -t 0 ]; then print_error "Interactive mode requires a terminal (TTY)." - print_error "Please run from a terminal, or use quick install mode:" - print_error " curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash" + print_error "Please run from a terminal, or use automatic mode:" + print_error " curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install.sh | bash -s -- --auto" exit 1 fi @@ -467,20 +555,12 @@ if [[ "$INTERACTIVE_MODE" == "yes" ]]; then run_interactive_install fi -# Auto-detect GPU for non-interactive mode when no explicit Whisper choice -# This helps users who run curl | bash without any flags -if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ "$WITH_WHISPER" == "no" ]] && [[ "$NO_WHISPER_EXPLICIT" == "no" ]] && [[ "$INTERACTIVE_MODE" == "no" ]]; then - detect_nvidia_gpu || true - if [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then - WITH_WHISPER="yes" - print_info "NVIDIA GPU detected: $GPU_NAME" - print_info "Installing Whisper with GPU support..." - else - WITH_WHISPER="yes" - WHISPER_CPU="yes" - print_info "No NVIDIA GPU detected" - print_info "Installing Whisper with CPU-only PyTorch (~200MB)..." - fi +# Set default engine for auto/non-interactive mode +if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ -z "$SELECTED_ENGINE" ]]; then + # Default to whisper.cpp for best performance + SELECTED_ENGINE="whisper_cpp" + print_info "Automatic mode: Installing with whisper.cpp (default engine)" + print_info "For other engines, use: --engine=whisper or --engine=vosk" echo "" fi @@ -524,13 +604,13 @@ install_system_dependencies() { fi # Define package names for different distributions - local APT_PACKAGES_UBUNTU="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 libgirepository1.0-dev python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip" + local APT_PACKAGES_UBUNTU="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 libgirepository1.0-dev python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip vulkan-tools" local APT_PACKAGES_DEBIAN_BASE="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 libcairo2-dev python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip" local APT_PACKAGES_DEBIAN_11_12="$APT_PACKAGES_DEBIAN_BASE libgirepository1.0-dev gir1.2-ayatanaappindicator3-0.1" local APT_PACKAGES_DEBIAN_13_PLUS="$APT_PACKAGES_DEBIAN_BASE libgirepository-2.0-dev gir1.2-ayatanaappindicator3-0.1" - local DNF_PACKAGES="python3-pip python3-gobject gtk3 libappindicator-gtk3 gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config wget curl unzip" - local PACMAN_PACKAGES="python-pip python-gobject gtk3 libappindicator-gtk3 gobject-introspection python-cairo portaudio python-virtualenv pkg-config wget curl unzip base-devel" - local ZYPPER_PACKAGES="python3-pip python3-gobject python3-gobject-cairo gtk3 libappindicator-gtk3 gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config wget curl unzip" + local DNF_PACKAGES="python3-pip python3-gobject gtk3 libappindicator-gtk3 gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config wget curl unzip vulkan-tools" + local PACMAN_PACKAGES="python-pip python-gobject gtk3 libappindicator-gtk3 gobject-introspection python-cairo portaudio python-virtualenv pkg-config wget curl unzip base-devel vulkan-tools" + local ZYPPER_PACKAGES="python3-pip python3-gobject python3-gobject-cairo gtk3 libappindicator-gtk3 gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config wget curl unzip vulkan-tools" # Gentoo uses Portage and different package naming convention local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libappindicator:3 media-libs/portaudio dev-lang/python:3.8 pkgconf" # Alpine Linux uses apk and has musl libc @@ -1241,33 +1321,111 @@ install_python_package() { else print_info "Installing Vocalinux..." - # Install the package with logging + # Install the package with logging (includes pywhispercpp by default) pip install . --log "$PIP_LOG_FILE" || { print_error "Failed to install Vocalinux." print_error "Check the pip log for details: $PIP_LOG_FILE" return 1 } - # Whisper installation logic: - # - Default (non-interactive via curl|bash): Install Whisper - # - --no-whisper flag: Skip Whisper, create config with VOSK as default - # - Interactive mode: Prompt user - if [ "$NO_WHISPER_EXPLICIT" = "yes" ]; then - # User explicitly requested --no-whisper (for low-RAM systems) - print_info "Skipping Whisper installation (--no-whisper flag)." - print_info "VOSK will be used as the default speech recognition engine." - - # Create config file with VOSK as default engine - local CONFIG_FILE="$CONFIG_DIR/config.json" - if [ ! -f "$CONFIG_FILE" ]; then - print_info "Creating config with VOSK as default engine..." - cat > "$CONFIG_FILE" << 'VOSK_CONFIG' + # Engine installation logic: + # - SELECTED_ENGINE is set by interactive mode or --engine flag + # - Default is whisper_cpp for best performance + case "${SELECTED_ENGINE:-whisper_cpp}" in + whisper_cpp) + print_info "" + print_info "╔════════════════════════════════════════════════════════╗" + print_info "║ Installing WHISPER.CPP (Recommended) ║" + print_info "╠════════════════════════════════════════════════════════╣" + print_info "║ • Fastest speech recognition ║" + print_info "║ • Works with any GPU: NVIDIA, AMD, Intel ║" + print_info "║ • Uses Vulkan for GPU acceleration ║" + print_info "║ • CPU-only mode available ║" + print_info "╚════════════════════════════════════════════════════════╝" + print_info "" + print_info "whisper.cpp is included by default and ready to use!" + print_info "" + + # Check for GPU acceleration support + detect_vulkan || true + if [[ "$HAS_VULKAN" == "yes" ]]; then + print_info "✓ Vulkan detected: $VULKAN_DEVICE" + print_info " GPU acceleration will be used automatically" + elif [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then + print_info "✓ NVIDIA GPU detected: $GPU_NAME" + print_info " CUDA acceleration will be used automatically" + else + print_info "ℹ No GPU detected - whisper.cpp will use CPU mode" + print_info " CPU mode is still very fast!" + fi + echo "" + ;; + + whisper) + print_info "Installing Whisper (OpenAI) with PyTorch..." + print_info "Note: This engine requires NVIDIA GPU for acceleration" + print_info " For AMD/Intel GPUs, whisper.cpp is recommended" + + # Install PyTorch and whisper + pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --log "$PIP_LOG_FILE" || { + print_warning "Failed to install PyTorch." + } + pip install openai-whisper --log "$PIP_LOG_FILE" || { + print_warning "Failed to install Whisper." + print_warning "Falling back to VOSK." + } + + # Create config with whisper as default + local WHISPER_CONFIG="$CONFIG_DIR/config.json" + if [ ! -f "$WHISPER_CONFIG" ]; then + mkdir -p "$CONFIG_DIR" + cat > "$WHISPER_CONFIG" << 'WHISPER_CONFIG' +{ + "speech_recognition": { + "engine": "whisper", + "model_size": "tiny", + "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 + } +} +WHISPER_CONFIG + fi + ;; + + vosk) + print_info "Installing VOSK (lightweight option)..." + print_info "VOSK is fast and works well on older systems." + + # Create config with vosk as default + local VOSK_CONFIG_FILE="$CONFIG_DIR/config.json" + if [ ! -f "$VOSK_CONFIG_FILE" ]; then + mkdir -p "$CONFIG_DIR" + cat > "$VOSK_CONFIG_FILE" << '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 }, @@ -1288,98 +1446,9 @@ install_python_package() { } } VOSK_CONFIG - fi - elif [ "$WHISPER_CPU" = "yes" ]; then - # CPU-only Whisper installation (smaller download, works on low-RAM systems) - print_info "Installing Whisper AI with CPU-only PyTorch (~200MB instead of ~2.3GB)..." - print_info "This is ideal for systems without NVIDIA GPU or with limited RAM." - - # Install CPU-only PyTorch first - pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --log "$PIP_LOG_FILE" || { - print_warning "Failed to install CPU-only PyTorch." - print_warning "Voice recognition will fall back to VOSK." - } - - # Install openai-whisper (without its torch dependency since we already installed it) - pip install openai-whisper --log "$PIP_LOG_FILE" || { - print_warning "Failed to install Whisper." - print_warning "Voice recognition will fall back to VOSK." - } - elif [ "$WITH_WHISPER" = "yes" ]; then - # Install Whisper with GPU support (set by --with-whisper flag or auto-detected in non-interactive mode) - print_info "Installing Whisper AI support with GPU/CUDA (~2.3GB download, ~5-10 min)..." - print_info "This enables high-accuracy speech recognition with GPU acceleration." - print_info "Tip: Use --whisper-cpu for a smaller download if you don't have an NVIDIA GPU." - pip install ".[whisper]" --log "$PIP_LOG_FILE" || { - print_warning "Failed to install Whisper support." - print_warning "Voice recognition will fall back to VOSK." - } - else - # Interactive mode: Prompt user - echo "" - print_info "Choose installation type:" - print_info " 1) Full install - GPU (~2.3GB) - Whisper + VOSK, best accuracy" - print_info " 2) Full install - CPU (~200MB) - Whisper + VOSK, no GPU needed" - print_info " 3) Minimal install (~40MB) - VOSK only, lightweight" - read -p "Choose option [1/2/3]: " -n 1 -r - echo - if [[ $REPLY == "1" ]]; then - WITH_WHISPER="yes" - print_info "Installing full (Whisper + VOSK) with GPU support..." - pip install ".[whisper]" --log "$PIP_LOG_FILE" || { - print_warning "Failed to install Whisper support." - print_warning "Voice recognition will fall back to VOSK." - } - elif [[ $REPLY == "2" ]]; then - WITH_WHISPER="yes" - WHISPER_CPU="yes" - print_info "Installing full (Whisper + VOSK) with CPU-only PyTorch..." - pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --log "$PIP_LOG_FILE" || { - print_warning "Failed to install CPU-only PyTorch." - } - pip install openai-whisper --log "$PIP_LOG_FILE" || { - print_warning "Failed to install Whisper." - print_warning "Voice recognition will fall back to VOSK." - } - elif [[ $REPLY == "3" ]]; then - # User selected minimal install (VOSK only) - NO_WHISPER_EXPLICIT="yes" - print_info "Installing minimal (VOSK only)..." - - # Create config file with VOSK as default engine - local CONFIG_FILE_PATH="$CONFIG_DIR/config.json" - if [ ! -f "$CONFIG_FILE_PATH" ]; then - print_info "Creating config with VOSK as default engine..." - cat > "$CONFIG_FILE_PATH" << 'VOSK_INTERACTIVE_CONFIG' -{ - "speech_recognition": { - "engine": "vosk", - "model_size": "small", - "vosk_model_size": "small", - "whisper_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 - } -} -VOSK_INTERACTIVE_CONFIG fi - fi - fi + ;; + esac fi # Verify installation @@ -1624,6 +1693,83 @@ install_vosk_models() { fi } +# Function to download and install whisper.cpp tiny model +install_whispercpp_model() { + print_info "Installing whisper.cpp tiny model (~39MB)..." + + # Create whisper.cpp models directory + local WHISPERCPP_DIR="$DATA_DIR/models/whispercpp" + mkdir -p "$WHISPERCPP_DIR" + + # whisper.cpp tiny model URL and path + local TINY_MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin" + local TINY_MODEL_PATH="$WHISPERCPP_DIR/ggml-tiny.bin" + + # Check if model already exists + if [ -f "$TINY_MODEL_PATH" ]; then + print_info "whisper.cpp tiny model already exists at $TINY_MODEL_PATH" + return 0 + fi + + # Check internet connectivity + if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then + print_warning "Neither wget nor curl found. Cannot download whisper.cpp model." + print_warning "Model will be downloaded on first application run." + return 1 + fi + + # Test internet connectivity + if ! ping -c 1 google.com >/dev/null 2>&1; then + print_warning "No internet connection detected." + print_warning "whisper.cpp model will be downloaded on first application run." + return 1 + fi + + print_info "Downloading whisper.cpp tiny model..." + print_info "This may take a few minutes depending on your internet connection." + + local TEMP_FILE="$TINY_MODEL_PATH.tmp" + + # Download the model + if command -v wget >/dev/null 2>&1; then + if ! wget --progress=bar:force:noscroll -O "$TEMP_FILE" "$TINY_MODEL_URL" 2>&1; then + print_error "Failed to download whisper.cpp model with wget" + rm -f "$TEMP_FILE" + return 1 + fi + elif command -v curl >/dev/null 2>&1; then + if ! curl -L --progress-bar -o "$TEMP_FILE" "$TINY_MODEL_URL"; then + print_error "Failed to download whisper.cpp model with curl" + rm -f "$TEMP_FILE" + return 1 + fi + fi + + # Verify download + if [ ! -f "$TEMP_FILE" ] || [ ! -s "$TEMP_FILE" ]; then + print_error "Downloaded model file is empty or missing" + rm -f "$TEMP_FILE" + return 1 + fi + + # Move to final location + mv "$TEMP_FILE" "$TINY_MODEL_PATH" + + # Verify the model file + if [ -f "$TINY_MODEL_PATH" ]; then + local MODEL_SIZE=$(du -h "$TINY_MODEL_PATH" | cut -f1) + print_success "whisper.cpp tiny model installed successfully ($MODEL_SIZE)" + + # Create a marker file to indicate this model was pre-installed + echo "$(date)" > "$WHISPERCPP_DIR/.vocalinux_preinstalled" + + return 0 + else + print_error "whisper.cpp model installation failed" + return 1 + fi +} + # Function to install desktop entry with error handling install_desktop_entry() { print_info "Installing desktop entry..." @@ -1748,27 +1894,28 @@ install_desktop_entry || print_warning "Desktop entry installation failed" # Install icons install_icons || print_warning "Icon installation failed" -# Install Whisper tiny model if Whisper is available -# This is important because Whisper is the default engine in config_manager.py -# We check if Whisper was actually installed (importable) rather than relying on flags +# Install models based on selected engine +# whisper.cpp is now the default engine 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 + 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 + + # Install OpenAI Whisper model if whisper engine is installed if "$VENV_DIR/bin/python" -c "import whisper" 2>/dev/null; then - print_info "Whisper is installed - downloading tiny model (default engine)..." + print_info "Whisper (OpenAI) is installed - downloading tiny model..." install_whisper_model || print_warning "Whisper model download failed - model will be downloaded on first run" - elif [ "$NO_WHISPER_EXPLICIT" != "yes" ]; then - # Whisper is not installed but wasn't explicitly disabled - # This shouldn't happen in normal flow, but warn the user - print_warning "Whisper not available but is the default engine." - print_warning "The app will try to download the model on first run." - else - print_info "Whisper not installed (VOSK-only mode), skipping Whisper model download" fi else - print_info "Skipping Whisper model download (--skip-models specified)" - print_info "Model will be downloaded automatically on first application run" + print_info "Skipping model downloads (--skip-models specified)" + print_info "Models will be downloaded automatically on first application run" fi -# Install VOSK models (always useful as fallback, and required for VOSK-only mode) +# Install VOSK models (always useful as fallback) if [ "$SKIP_MODELS" = "no" ]; then install_vosk_models || print_warning "VOSK model installation failed - models will be downloaded on first run" else diff --git a/pyproject.toml b/pyproject.toml index 743bfd8..d7e6432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ classifiers = [ ] dependencies = [ "vosk>=0.3.45", + "pywhispercpp>=1.2.0", "pydub>=0.25.1", "pynput>=1.7.6", "evdev>=1.6.0; sys_platform == 'linux'", diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 7e4d749..49b4691 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -57,8 +57,8 @@ def parse_arguments(): parser.add_argument( "--engine", type=str, - choices=["vosk", "whisper"], - help="Speech recognition engine to use", + choices=["vosk", "whisper", "whisper_cpp"], + help="Speech recognition engine to use (whisper_cpp recommended for best performance)", ) parser.add_argument("--wayland", action="store_true", help="Force Wayland compatibility mode") return parser.parse_args() diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index dfe29fb..7c27706 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -2,13 +2,14 @@ Speech recognition manager module for Vocalinux. This module provides a unified interface to different speech recognition engines, -currently supporting VOSK and Whisper. +currently supporting VOSK, Whisper, and whisper.cpp. """ import ctypes import json import logging import os +import sys import threading import time from typing import Callable, List, Optional @@ -16,6 +17,11 @@ from typing import Callable, List, Optional from ..common_types import RecognitionState from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound from ..utils.vosk_model_info import VOSK_MODEL_INFO +from ..utils.whispercpp_model_info import ( + WHISPERCPP_MODEL_INFO, + get_model_path, + is_model_downloaded, +) from .command_processor import CommandProcessor @@ -368,6 +374,8 @@ class SpeechRecognitionManager: self._init_vosk() elif engine == "whisper": self._init_whisper() + elif engine == "whisper_cpp": + self._init_whispercpp() else: raise ValueError(f"Unsupported speech recognition engine: {engine}") @@ -554,6 +562,273 @@ class SpeechRecognitionManager: logger.error(f"Error in Whisper transcription: {e}", exc_info=True) return "" + def _init_whispercpp(self): + """Initialize the whisper.cpp speech recognition engine.""" + import time + + try: + from pywhispercpp.model import Model + + # Validate model size for whisper.cpp + valid_models = list(WHISPERCPP_MODEL_INFO.keys()) + if self.model_size not in valid_models: + logger.warning( + f"Model size '{self.model_size}' not valid for whisper.cpp. " + f"Valid options: {valid_models}. Using 'tiny' instead." + ) + self.model_size = "tiny" + + # Check if model is downloaded + model_path = get_model_path(self.model_size) + + if not os.path.exists(model_path): + if self._defer_download: + logger.info( + f"whisper.cpp model '{self.model_size}' not found at {model_path}. " + "Will download when needed." + ) + self._model_initialized = False + return # Don't block startup + else: + logger.info(f"Downloading whisper.cpp '{self.model_size}' model...") + self._download_whispercpp_model() + + # Detect and log compute backend + from ..utils.whispercpp_model_info import ( + ComputeBackend, + detect_compute_backend, + get_backend_display_name, + ) + + 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}" + ) + + # 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") + + # 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)") + else: + logger.error(f"whisper.cpp model file not found: {model_path}") + raise FileNotFoundError(f"Model file not found: {model_path}") + + logger.info(f"Loading whisper.cpp '{self.model_size}' model...") + # Ensure previous model is released if re-initializing + self.model = None + + # Load model with pywhispercpp + # It auto-detects the best backend (Vulkan, CUDA, or CPU) + # Use all available CPU cores for best performance + import multiprocessing + + n_threads = multiprocessing.cpu_count() + cpu_count = multiprocessing.cpu_count() + + load_start_time = time.time() + self.model = Model(model_path, n_threads=n_threads) + load_duration = time.time() - load_start_time + + logger.info( + f"whisper.cpp configured with n_threads={n_threads} (detected {cpu_count} CPUs)" + ) + logger.info(f"whisper.cpp model loaded in {load_duration:.2f}s ({backend} backend)") + + self._model_initialized = True + logger.info("whisper.cpp engine initialized successfully.") + + except ImportError as e: + logger.error(f"Failed to import pywhispercpp: {e}") + logger.error(f"Python path: {sys.path}") + logger.error("Please install with: pip install pywhispercpp") + self.state = RecognitionState.ERROR + raise + except Exception as e: + logger.error(f"Failed to initialize whisper.cpp engine: {e}", exc_info=True) + self.state = RecognitionState.ERROR + raise + + def _transcribe_with_whispercpp(self, audio_buffer: List[bytes]) -> str: + """ + Transcribe audio buffer using whisper.cpp. + + Args: + audio_buffer: List of audio data chunks (16-bit PCM at 16kHz) + + Returns: + Transcribed text + """ + import time + + try: + import numpy as np + + if not audio_buffer: + return "" + + # Convert audio buffer to numpy array + audio_data = np.frombuffer(b"".join(audio_buffer), dtype=np.int16) + + # Convert to float32 and normalize to [-1, 1] + audio_float = audio_data.astype(np.float32) / 32768.0 + + duration = len(audio_float) / 16000.0 # 16kHz sample rate + num_chunks = len(audio_buffer) + logger.debug( + f"whisper.cpp audio preprocessing: {len(audio_float)} samples, {duration:.2f}s, {num_chunks} chunks" + ) + + # Prepare language parameter + lang = self.language + if self.language == "en-us": + lang = "en" + elif self.language == "auto": + lang = None # Auto-detect + + logger.debug(f"whisper.cpp using language: {lang or 'auto-detect'}") + + # Transcribe with whisper.cpp + # pywhispercpp expects audio as numpy array + transcribe_start = time.time() + segments = self.model.transcribe(audio_float, language=lang) + transcribe_duration = time.time() - transcribe_start + + # Extract text from segments + text_parts = [] + for segment in segments: + if hasattr(segment, "text") and segment.text: + text_parts.append(segment.text.strip()) + + text = " ".join(text_parts).strip() + num_segments = len(text_parts) + + # Calculate RTF (Real-Time Factor) + rtf = transcribe_duration / duration if duration > 0 else 0 + + if text: + logger.info(f"whisper.cpp transcribed: '{text}'") + logger.info( + f"whisper.cpp transcription completed in {transcribe_duration:.3f}s for {duration:.2f}s audio (RTF: {rtf:.2f}x) - {num_segments} segments" + ) + else: + logger.debug( + f"whisper.cpp returned empty transcription ({transcribe_duration:.3f}s)" + ) + + return text + + except Exception as e: + audio_info = ( + f"audio buffer: {len(audio_buffer)} chunks" + if audio_buffer + else "empty audio buffer" + ) + logger.error(f"Error in whisper.cpp transcription: {e} ({audio_info})", exc_info=True) + return "" + + def _download_whispercpp_model(self): + """Download a whisper.cpp model with progress tracking.""" + import requests + + self._download_cancelled = False + + model_info = WHISPERCPP_MODEL_INFO.get(self.model_size) + if not model_info: + raise ValueError(f"Unknown whisper.cpp model size: {self.model_size}") + + url = model_info["url"] + model_path = get_model_path(self.model_size) + temp_file = model_path + ".tmp" + + # Ensure directory exists + os.makedirs(os.path.dirname(model_path), exist_ok=True) + + logger.info(f"Downloading whisper.cpp {self.model_size} model to {model_path}") + logger.info(f"Downloading from {url}") + + try: + response = requests.get(url, stream=True) + response.raise_for_status() + + total_size = int(response.headers.get("content-length", 0)) + downloaded_size = 0 + start_time = time.time() + last_update_time = start_time + chunk_size = 8192 # 8KB chunks + + with open(temp_file, "wb") as f: + for data in response.iter_content(chunk_size=chunk_size): + if self._download_cancelled: + logger.info("Download cancelled by user") + f.close() + if os.path.exists(temp_file): + os.remove(temp_file) + raise RuntimeError("Download cancelled") + + f.write(data) + downloaded_size += len(data) + + # Update progress callback + current_time = time.time() + if ( + self._download_progress_callback + and (current_time - last_update_time) >= 0.1 + ): + elapsed = current_time - start_time + if elapsed > 0: + speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed + else: + speed_mbps = 0 + + if total_size > 0: + progress = downloaded_size / total_size + remaining_mb = (total_size - downloaded_size) / (1024 * 1024) + if speed_mbps > 0: + eta_seconds = remaining_mb / speed_mbps + eta_str = ( + f"{int(eta_seconds)}s" + if eta_seconds < 60 + else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s" + ) + else: + eta_str = "--" + status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}" + else: + progress = 0 + status = ( + f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s" + ) + + self._download_progress_callback(progress, speed_mbps, status) + last_update_time = current_time + + logger.info(f"Download progress: {progress * 100:.1f}% - {status}") + + # Rename temp file to final + os.rename(temp_file, model_path) + logger.info("whisper.cpp model downloaded successfully") + + if self._download_progress_callback: + self._download_progress_callback(1.0, 0, "Complete!") + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to download whisper.cpp model from {url}: {e}") + if os.path.exists(temp_file): + os.remove(temp_file) + raise RuntimeError(f"Failed to download whisper.cpp model: {e}") from e + except Exception as e: + logger.error(f"An error occurred during whisper.cpp model download: {e}") + if os.path.exists(temp_file): + os.remove(temp_file) + raise + def _get_vosk_model_path(self) -> str: """Get the path to the VOSK model based on the selected size and language.""" model_name = self.vosk_model_map.get(self.model_size, self.vosk_model_map["small"]) @@ -1264,6 +1539,9 @@ class SpeechRecognitionManager: elif self.engine == "whisper": text = self._transcribe_with_whisper(self.audio_buffer) + elif self.engine == "whisper_cpp": + text = self._transcribe_with_whispercpp(self.audio_buffer) + else: logger.error(f"Unknown engine: {self.engine}") return @@ -1357,6 +1635,8 @@ class SpeechRecognitionManager: self._init_vosk() elif self.engine == "whisper": self._init_whisper() + elif self.engine == "whisper_cpp": + self._init_whispercpp() else: raise ValueError(f"Unsupported engine during reconfigure: {self.engine}") logger.info("Speech engine re-initialized successfully.") diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 144c220..da155b3 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -18,11 +18,12 @@ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") # Default configuration DEFAULT_CONFIG = { "speech_recognition": { # Changed section name - "engine": "whisper", # "vosk" or "whisper" - whisper is default for better accuracy - "language": "auto", # Auto-detect language (Whisper only) + "engine": "whisper_cpp", # "vosk", "whisper", or "whisper_cpp" - whisper_cpp is default for best performance + "language": "auto", # Auto-detect language (Whisper/whisper.cpp only) "model_size": "tiny", # Current model size (for backward compatibility) "vosk_model_size": "small", # Default model for VOSK engine "whisper_model_size": "tiny", # Default model for Whisper engine + "whisper_cpp_model_size": "tiny", # Default model for whisper.cpp engine "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) "silence_timeout": 2.0, # Seconds of silence before stopping }, @@ -194,7 +195,7 @@ class ConfigManager: """Get the saved model size for a specific engine. Args: - engine: The engine name ("vosk" or "whisper") + engine: The engine name ("vosk", "whisper", or "whisper_cpp") Returns: The model size for the engine, or the default if not found diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 716d948..89019e0 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -29,6 +29,13 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 +from ..utils.whispercpp_model_info import ( + WHISPERCPP_MODEL_INFO, + detect_compute_backend, + get_backend_display_name, +) +from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model +from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded from .keyboard_backends import SHORTCUT_DISPLAY_NAMES, SUPPORTED_SHORTCUTS # noqa: E402 # Avoid circular imports for type checking @@ -52,6 +59,13 @@ ENGINE_MODELS = { "medium", "large", ], # Add more whisper sizes if needed + "whisper_cpp": [ + "tiny", + "base", + "small", + "medium", + "large", + ], # whisper.cpp models (ggml format) } # Whisper model metadata for display @@ -63,6 +77,42 @@ WHISPER_MODEL_INFO = { "large": {"size_mb": 2900, "desc": "Highest accuracy, slowest", "params": "1550M"}, } + +def get_available_engines(): + """ + Detect which speech recognition engines are available/installed. + Returns a dictionary of engine_name -> availability (bool). + """ + engines = {"vosk": False, "whisper": False, "whisper_cpp": False} + + # Check VOSK + try: + import vosk + + engines["vosk"] = True + except ImportError: + pass + + # Check OpenAI Whisper + try: + import whisper + + engines["whisper"] = True + except ImportError: + pass + + # Check whisper.cpp (pywhispercpp) + try: + from pywhispercpp.model import Model + + engines["whisper_cpp"] = True + except ImportError: + pass + + logger.debug(f"Available engines: {engines}") + return engines + + # Models directory MODELS_DIR = os.path.expanduser("~/.local/share/vocalinux/models") SYSTEM_MODELS_DIRS = [ @@ -635,7 +685,25 @@ class SettingsDialog(Gtk.Dialog): # Dialog configuration - Close button only (instant-apply pattern) self.add_button("_Close", Gtk.ResponseType.CLOSE) - self.set_default_size(520, 680) + # Calculate dialog size - 75% of screen height for better visibility + display = Gdk.Display.get_default() + if display: + monitor = display.get_primary_monitor() + if not monitor and display.get_n_monitors() > 0: + monitor = display.get_monitor(0) + if monitor: + geometry = monitor.get_geometry() + screen_height = geometry.height + screen_width = geometry.width + else: + screen_height = 1080 # Default fallback + screen_width = 1920 + else: + screen_height = 1080 # Default fallback + screen_width = 1920 + dialog_height = int(screen_height * 0.75) + dialog_width = min(520, int(screen_width * 0.8)) + self.set_default_size(dialog_width, dialog_height) self.get_style_context().add_class("settings-dialog") # Main scrolled window for content @@ -1041,20 +1109,50 @@ class SettingsDialog(Gtk.Dialog): f"Starting dialog with settings: engine={self.current_engine}, model={self.current_model_size}" ) - # Populate engine combo + # Populate engine combo with only available engines + available_engines = get_available_engines() + available_count = 0 + for engine in ENGINE_MODELS.keys(): - capitalized_engine = engine.capitalize() - self.engine_combo.append(capitalized_engine, capitalized_engine) + if available_engines.get(engine, False): + capitalized_engine = engine.capitalize() + self.engine_combo.append(capitalized_engine, capitalized_engine) + available_count += 1 + + if available_count == 0: + logger.error("No speech recognition engines available!") + # Still add them so the UI works, but log the error + for engine in ENGINE_MODELS.keys(): + capitalized_engine = engine.capitalize() + self.engine_combo.append(capitalized_engine, capitalized_engine) + else: + logger.info(f"Populated {available_count} available engines: {available_engines}") + + # Set engine active - check if current engine is available + if not available_engines.get(self.current_engine, False): + logger.warning( + f"Current engine '{self.current_engine}' is not available, selecting first available" + ) + # Find first available engine + for engine in ENGINE_MODELS.keys(): + if available_engines.get(engine, False): + self.current_engine = engine + break - # Set engine active engine_text = self.current_engine.capitalize() logger.info(f"Setting active engine to: {engine_text}") if not self.engine_combo.set_active_id(engine_text): logger.warning("Could not set engine by ID, trying by index") - if self.current_engine == "vosk": - self.engine_combo.set_active(0) - elif self.current_engine == "whisper": - self.engine_combo.set_active(1) + # Find index of current engine + model = self.engine_combo.get_model() + for i, row in enumerate(model): + if row[0].lower() == self.current_engine.lower(): + self.engine_combo.set_active(i) + break + else: + # Fallback to first available + if self.engine_combo.get_model(): + self.engine_combo.set_active(0) # Populate model options for the selected engine self._populate_model_options() @@ -1115,17 +1213,21 @@ class SettingsDialog(Gtk.Dialog): downloaded_models = [] smallest_model = None - recommended_model, _ = ( - _get_recommended_whisper_model() - if engine == "whisper" - else _get_recommended_vosk_model() - ) + if engine == "whisper": + recommended_model, _ = _get_recommended_whisper_model() + elif engine == "whisper_cpp": + recommended_model, _ = get_recommended_whispercpp_model() + else: + recommended_model, _ = _get_recommended_vosk_model() if engine in ENGINE_MODELS: for size in ENGINE_MODELS[engine]: if engine == "whisper" and size in WHISPER_MODEL_INFO: info = WHISPER_MODEL_INFO[size] is_downloaded = _is_whisper_model_downloaded(size) + elif engine == "whisper_cpp" and size in WHISPERCPP_MODEL_INFO: + info = WHISPERCPP_MODEL_INFO[size] + is_downloaded = is_whispercpp_model_downloaded(size) elif engine == "vosk" and size in VOSK_MODEL_INFO: info = VOSK_MODEL_INFO[size] is_downloaded = _is_vosk_model_downloaded(size, self.language) @@ -1186,7 +1288,7 @@ class SettingsDialog(Gtk.Dialog): current_lang == "auto" or not SUPPORTED_LANGUAGES.get(current_lang, {}).get("vosk") ): self.language = "en-us" - elif engine == "whisper" and not current_lang: + elif engine in ["whisper", "whisper_cpp"] and not current_lang: self.language = "auto" self._populate_model_options() @@ -1229,7 +1331,8 @@ class SettingsDialog(Gtk.Dialog): continue is_downloaded = _is_vosk_model_downloaded("small", lang_code) display_text += " ✓" if is_downloaded else " ↓" - elif engine == "whisper": + elif engine in ["whisper", "whisper_cpp"]: + # Both Whisper and whisper.cpp support auto-detect if lang_code == "auto": display_text += " ⚠" else: @@ -1297,6 +1400,17 @@ class SettingsDialog(Gtk.Dialog): is_downloaded = _is_whisper_model_downloaded(model_name) recommended, reason = _get_recommended_whisper_model() extra_info = f"Parameters: {info['params']}" + elif engine == "whisper_cpp": + if model_name not in WHISPERCPP_MODEL_INFO: + self.model_info_card.hide() + return + info = WHISPERCPP_MODEL_INFO[model_name] + is_downloaded = is_whispercpp_model_downloaded(model_name) + recommended, reason = get_recommended_whispercpp_model() + backend, backend_info = detect_compute_backend() + extra_info = ( + f"Parameters: {info['params']} • Backend: {get_backend_display_name(backend)}" + ) elif engine == "vosk": if model_name not in VOSK_MODEL_INFO: self.model_info_card.hide() @@ -1357,6 +1471,9 @@ class SettingsDialog(Gtk.Dialog): if engine == "whisper" and not _is_whisper_model_downloaded(model_name): needs_download = True model_info = WHISPER_MODEL_INFO.get(model_name, {"size_mb": 500}) + elif engine == "whisper_cpp" and not is_whispercpp_model_downloaded(model_name): + needs_download = True + model_info = WHISPERCPP_MODEL_INFO.get(model_name, {"size_mb": 39}) elif engine == "vosk" and not _is_vosk_model_downloaded(model_name, self.language): needs_download = True model_info = VOSK_MODEL_INFO.get(model_name, {"size_mb": 50}) diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py new file mode 100644 index 0000000..c823dc2 --- /dev/null +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -0,0 +1,280 @@ +""" +Whisper.cpp model information and hardware detection for Vocalinux. + +This module provides model metadata and hardware acceleration detection +for whisper.cpp, supporting Vulkan, CUDA, and CPU backends. +""" + +import logging +import os +import subprocess +from typing import Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +# Whisper.cpp model information +# Models are downloaded from Hugging Face (ggml format) +WHISPERCPP_MODEL_INFO = { + "tiny": { + "size_mb": 39, + "params": "39M", + "desc": "Fastest, lowest accuracy", + "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin", + }, + "base": { + "size_mb": 74, + "params": "74M", + "desc": "Fast, good for basic use", + "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin", + }, + "small": { + "size_mb": 244, + "params": "244M", + "desc": "Balanced speed/accuracy", + "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", + }, + "medium": { + "size_mb": 769, + "params": "769M", + "desc": "High accuracy, slower", + "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin", + }, + "large": { + "size_mb": 1550, + "params": "1550M", + "desc": "Highest accuracy, slowest", + "url": "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin", + }, +} + +# Available models list +AVAILABLE_MODELS = list(WHISPERCPP_MODEL_INFO.keys()) + + +# Compute backend types +class ComputeBackend: + """Compute backend options for whisper.cpp.""" + + VULKAN = "vulkan" + CUDA = "cuda" + CPU = "cpu" + + +def detect_vulkan_support() -> Tuple[bool, Optional[str]]: + """ + Detect if Vulkan is available and get device info. + + Returns: + Tuple of (is_available, device_name) + """ + try: + # Check for vulkaninfo command + result = subprocess.run( + ["vulkaninfo", "--summary"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + # Try to extract GPU name from output + for line in result.stdout.split("\n"): + if "deviceName" in line or "GPU" in line: + device_name = line.split(":")[-1].strip() + if device_name: + logger.info(f"Vulkan support detected: {device_name}") + return True, device_name + logger.info("Vulkan support detected") + return True, "Vulkan GPU" + except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: + logger.debug(f"Vulkan detection failed: {e}") + + return False, None + + +def detect_cuda_support() -> Tuple[bool, Optional[str]]: + """ + Detect if NVIDIA CUDA is available and get device info. + + Returns: + Tuple of (is_available, device_info) + """ + try: + # Check for nvidia-smi + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + gpu_info = result.stdout.strip().split(",") + if gpu_info: + gpu_name = gpu_info[0].strip() + gpu_memory = gpu_info[1].strip() if len(gpu_info) > 1 else "unknown" + logger.info(f"CUDA support detected: {gpu_name} ({gpu_memory})") + return True, f"{gpu_name} ({gpu_memory})" + except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: + logger.debug(f"CUDA detection failed: {e}") + + return False, None + + +def detect_compute_backend() -> Tuple[str, str]: + """ + Detect the best available compute backend. + + Priority order: Vulkan > CUDA > CPU + + Returns: + Tuple of (backend_type, backend_info) + """ + # Try Vulkan first (supports AMD, Intel, NVIDIA) + has_vulkan, vulkan_info = detect_vulkan_support() + if has_vulkan and vulkan_info: + return ComputeBackend.VULKAN, vulkan_info + + # Try CUDA next (NVIDIA only) + has_cuda, cuda_info = detect_cuda_support() + if has_cuda and cuda_info: + return ComputeBackend.CUDA, cuda_info + + # Fall back to CPU + cpu_info = detect_cpu_info() + return ComputeBackend.CPU, cpu_info + + +def detect_cpu_info() -> str: + """ + Detect CPU information for CPU backend. + + Returns: + CPU info string + """ + try: + # Try to get CPU model from /proc/cpuinfo + with open("/proc/cpuinfo", "r") as f: + for line in f: + if "model name" in line: + cpu_name = line.split(":")[1].strip() + return cpu_name + except Exception as e: + logger.debug(f"Could not read CPU info: {e}") + + # Fallback to nproc + try: + result = subprocess.run( + ["nproc"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + cpu_count = result.stdout.strip() + return f"{cpu_count} cores" + except Exception: + pass + + return "CPU" + + +def get_recommended_model() -> Tuple[str, str]: + """ + Get the recommended whisper.cpp model based on system configuration. + + Returns: + Tuple of (model_name, reason) + """ + try: + import psutil + + ram_gb = psutil.virtual_memory().total // (1024**3) + + # Detect available compute backends + backend, backend_info = detect_compute_backend() + + if backend == ComputeBackend.VULKAN: + # Vulkan can handle larger models efficiently + if ram_gb >= 8: + return "small", f"Vulkan GPU with {ram_gb}GB RAM" + else: + return "base", f"Vulkan GPU with {ram_gb}GB RAM" + elif backend == ComputeBackend.CUDA: + # CUDA has more VRAM typically + if "GB" in backend_info: + try: + vram_gb = int(backend_info.split("GB")[0].split("(")[-1].strip()) + if vram_gb >= 8: + return "medium", f"CUDA GPU with {vram_gb}GB VRAM" + elif vram_gb >= 4: + return "small", f"CUDA GPU with {vram_gb}GB VRAM" + else: + return "base", f"CUDA GPU with limited VRAM" + except (ValueError, IndexError): + pass + return "small", f"CUDA GPU detected" + else: + # CPU-only recommendations based on RAM + if ram_gb >= 16: + return "base", f"{ram_gb}GB RAM - CPU inference" + elif ram_gb >= 8: + return "tiny", f"{ram_gb}GB RAM - optimized for speed" + else: + return "tiny", f"Limited RAM ({ram_gb}GB) - fastest model" + + except ImportError: + logger.debug("psutil not available for system detection") + + # Default recommendation + return "tiny", "Default recommendation" + + +def get_model_path(model_name: str) -> str: + """ + Get the path where a model should be stored. + + Args: + model_name: Name of the model (tiny, base, small, medium, large) + + Returns: + Path to the model file + """ + models_dir = os.path.expanduser("~/.local/share/vocalinux/models/whispercpp") + os.makedirs(models_dir, exist_ok=True) + + if model_name == "large": + # Large model uses v3 variant + return os.path.join(models_dir, "ggml-large-v3.bin") + else: + return os.path.join(models_dir, f"ggml-{model_name}.bin") + + +def is_model_downloaded(model_name: str) -> bool: + """ + Check if a whisper.cpp model is downloaded. + + Args: + model_name: Name of the model + + Returns: + True if model exists, False otherwise + """ + model_path = get_model_path(model_name) + return os.path.exists(model_path) + + +def get_backend_display_name(backend: str) -> str: + """ + Get a user-friendly display name for a compute backend. + + Args: + backend: Backend type (vulkan, cuda, cpu) + + Returns: + Display name string + """ + names = { + ComputeBackend.VULKAN: "Vulkan GPU", + ComputeBackend.CUDA: "NVIDIA CUDA", + ComputeBackend.CPU: "CPU", + } + return names.get(backend, backend.upper()) diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 3d646ad..09675e3 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -149,13 +149,13 @@ class TestConfigManager(unittest.TestCase): importlib.reload(cm) self.assertEqual( - cm.DEFAULT_CONFIG["speech_recognition"]["engine"], "whisper" - ) # Default is whisper for better accuracy + cm.DEFAULT_CONFIG["speech_recognition"]["engine"], "whisper_cpp" + ) # Default is whisper_cpp for best performance with Vulkan support # Also test the get method works - it should return a valid engine value config_manager = ConfigManager() value = config_manager.get("speech_recognition", "engine") - self.assertIn(value, ["vosk", "whisper"]) # Should be one of valid engines + self.assertIn(value, ["vosk", "whisper", "whisper_cpp"]) # Should be one of valid engines def test_get_nonexistent_value(self): """Test getting a nonexistent configuration value.""" diff --git a/uninstall.sh b/uninstall.sh index 209523b..fedd64a 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -47,6 +47,45 @@ safe_remove() { fi } +# Function to kill running Vocalinux processes +kill_vocalinux_processes() { + print_info "Checking for running Vocalinux processes..." + + # Find and kill vocalinux processes + local PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true) + + if [ -n "$PIDS" ]; then + print_warning "Found running Vocalinux process(es): $PIDS" + print_info "Stopping Vocalinux..." + + # Try graceful termination first + echo "$PIDS" | xargs -r kill -TERM 2>/dev/null || true + + # Wait a moment for processes to terminate + sleep 2 + + # Check if any processes are still running + local REMAINING_PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true) + + if [ -n "$REMAINING_PIDS" ]; then + print_warning "Some processes still running, forcing termination..." + echo "$REMAINING_PIDS" | xargs -r kill -KILL 2>/dev/null || true + sleep 1 + fi + + # Final check + local FINAL_PIDS=$(pgrep -f "vocalinux" 2>/dev/null || true) + if [ -n "$FINAL_PIDS" ]; then + print_error "Warning: Could not terminate all Vocalinux processes: $FINAL_PIDS" + print_error "You may need to manually kill these processes before continuing." + else + print_success "All Vocalinux processes stopped" + fi + else + print_info "No running Vocalinux processes found" + fi +} + print_info "Vocalinux Uninstaller" print_info "==============================" echo "" @@ -355,6 +394,9 @@ print_uninstallation_summary() { fi } +# Kill any running Vocalinux processes first +kill_vocalinux_processes + # Perform uninstallation steps remove_virtual_environment remove_curl_install_files