* fix(installer): add upgrade mode that keeps the existing configuration Re-running install.sh on an existing install replayed first-time setup: the mode menu offered only interactive or automatic, automatic mode forced whisper.cpp regardless of the configured engine, and nothing said config.json would be kept, even though docs/UPDATE.md promised the installer updates in place and preserves configuration. Detect an existing install early (venv activate + config.json) and offer an upgrade path: - The curl|bash mode menu gains 'Upgrade (keep current settings)' as the default when an install is detected; the guided setup offers it first too. - A --upgrade flag runs the same path non-interactively for scripts, and falls back to a normal automatic install when nothing is installed. - Upgrade mode reads speech_recognition.engine from the existing config.json instead of forcing whisper.cpp, reuses the venv, updates that engine's dependencies, and rebuilds pywhispercpp only when the installed version drifts from the pinned release or GPU support is missing. - config.json is never modified: every engine path that may rewrite it (parakeet, faster_whisper, the whisper.cpp -> vosk fallback) goes through a wrapper that is a logged no-op in upgrade mode, and verify_configured_engine reports an unimportable configured engine instead of repairing the file. - The installer prints 'Existing configuration found at ~/.config/vocalinux/config.json - it will be kept.' when it detects one. Update docs/UPDATE.md with the upgrade behavior and add tests/test_installer_upgrade.py covering detection, the mode menu default, engine resolution from config, config immutability, the guided offer, --upgrade parsing, and an end-to-end pty run of the piped installer. Fixes #1009 * test(installer): isolate the upgrade pty test from host changes Skip system-dependency and model steps in the end-to-end installer run so the only host-mutating calls are gated out entirely; the timed interrupt is no longer the only thing between the marker and sudo. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
3236 lines
123 KiB
Bash
Executable File
3236 lines
123 KiB
Bash
Executable File
#!/bin/bash
|
||
# Vocalinux Installer
|
||
# This script installs the Vocalinux application and its dependencies
|
||
|
||
# -E: ERR trap propagates into functions/subshells
|
||
# -e: exit on unhandled command failure -u: error on unset variables
|
||
# -o pipefail: a pipeline fails if any stage fails
|
||
set -Eeuo pipefail
|
||
INSTALLER_ARGS=("$@")
|
||
|
||
# Exit codes (see --help). 1 remains the generic/unclassified failure.
|
||
EXIT_OK=0
|
||
EXIT_MISSING_DEPS=2 # required system tools/packages could not be installed
|
||
EXIT_NETWORK=3 # connectivity failure or download/clone failure
|
||
EXIT_USER_ABORT=4 # user declined a prompt
|
||
|
||
# Keep the venv isolated from ~/.local site packages while still allowing
|
||
# --system-site-packages to expose distro-provided GTK/PyGObject bindings.
|
||
export PYTHONNOUSERSITE=1
|
||
|
||
# Function to display colored output
|
||
print_info() {
|
||
echo -e "\e[1;34m[INFO]\e[0m $1"
|
||
}
|
||
|
||
print_success() {
|
||
echo -e "\e[1;32m[SUCCESS]\e[0m $1"
|
||
}
|
||
|
||
# Diagnostics go to stderr, not stdout: `exec > >(tee ...) 2>&1` keeps them in the
|
||
# log and on screen either way, but stdout is what every $( ) captures. Writing
|
||
# them there let the ERR trap's own message land inside a captured value.
|
||
print_error() {
|
||
echo -e "\e[1;31m[ERROR]\e[0m $1" >&2
|
||
}
|
||
|
||
print_warning() {
|
||
echo -e "\e[1;33m[WARNING]\e[0m $1" >&2
|
||
}
|
||
|
||
command_exists() {
|
||
command -v "$1" >/dev/null 2>&1
|
||
}
|
||
|
||
# Read one interactive answer. When the installer itself is piped to bash
|
||
# (`curl ... | bash`), stdin still carries the script text to bash, so prompts
|
||
# must pull from the controlling terminal explicitly. A global
|
||
# `exec < /dev/tty` would close the pipe mid-script instead: bash loses the
|
||
# rest of the installer and the download dies with "curl: (23)".
|
||
read_prompt() {
|
||
if [ -t 0 ]; then
|
||
read "$@"
|
||
else
|
||
read "$@" < /dev/tty
|
||
fi
|
||
}
|
||
|
||
# The installer must build its venv from the *system* Python: distro PyGObject
|
||
# (python3-gi / python3-gobject) is compiled for that interpreter only. When the
|
||
# script starts inside an activated virtualenv — a shell left in uv's .venv
|
||
# after `just deps`, for instance — a bare `python3` resolves to that venv's
|
||
# interpreter instead, and a venv created from it cannot import gi even with
|
||
# --system-site-packages. Undo the activation for the installer's own process
|
||
# before anything looks up an interpreter.
|
||
deactivate_inherited_virtualenv() {
|
||
local venv_bin cleaned entry
|
||
|
||
[ -z "${VIRTUAL_ENV:-}" ] && return 0
|
||
|
||
print_warning "Running inside an activated virtualenv ($VIRTUAL_ENV)."
|
||
print_info "Ignoring it so the installation uses the system Python."
|
||
|
||
venv_bin="${VIRTUAL_ENV%/}/bin"
|
||
cleaned=""
|
||
while IFS= read -r entry; do
|
||
[ "$entry" = "$venv_bin" ] && continue
|
||
cleaned="${cleaned:+$cleaned:}$entry"
|
||
done < <(printf '%s\n' "${PATH//:/$'\n'}")
|
||
|
||
PATH="$cleaned"
|
||
export PATH
|
||
unset VIRTUAL_ENV
|
||
unset PYTHONHOME
|
||
}
|
||
|
||
deactivate_inherited_virtualenv
|
||
|
||
# HTTP-level connectivity check. ICMP ping is blocked on many networks
|
||
# (corporate firewalls, public Wi-Fi, CI runners), so probe the actual
|
||
# endpoints the installer depends on. Returns 0 if any probe succeeds.
|
||
check_connectivity() {
|
||
local url
|
||
for url in https://pypi.org/simple/ https://api.github.com/ https://huggingface.co/; do
|
||
if command_exists curl; then
|
||
if curl -fsI --connect-timeout 5 --max-time 10 -o /dev/null "$url" 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
elif command_exists wget; then
|
||
if wget -q --spider --timeout=10 "$url" 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
else
|
||
return 1
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
is_kde_plasma_session() {
|
||
local desktop="${XDG_CURRENT_DESKTOP:-} ${DESKTOP_SESSION:-} ${GDMSESSION:-}"
|
||
local kde_session="${KDE_FULL_SESSION:-}"
|
||
local desktop_lower="${desktop,,}"
|
||
local kde_session_lower="${kde_session,,}"
|
||
|
||
[[ "$desktop_lower" == *kde* || "$desktop_lower" == *plasma* || "$kde_session_lower" == "true" ]]
|
||
}
|
||
|
||
print_kde_wayland_ibus_hint() {
|
||
print_warning "KDE Plasma Wayland detected."
|
||
print_info "For direct dictation into apps, open System Settings -> Keyboard -> Virtual Keyboard and select 'IBus Wayland'."
|
||
print_info "After changing it, restart Vocalinux or log out and back in."
|
||
}
|
||
|
||
# Read a numeric PID from a file (instance.lock / engine.pid). Empty if missing/invalid.
|
||
read_pid_file() {
|
||
local file="$1"
|
||
local pid=""
|
||
|
||
[ -f "$file" ] || return 0
|
||
pid=$(tr -d '[:space:]' < "$file" 2>/dev/null || true)
|
||
if [[ "$pid" =~ ^[0-9]+$ ]]; then
|
||
echo "$pid"
|
||
fi
|
||
}
|
||
|
||
# True only for the Vocalinux app / IBus engine — not editors, shells, or tests
|
||
# whose argv merely contains the string "vocalinux" (e.g. cwd under the repo).
|
||
is_vocalinux_process() {
|
||
local pid="$1"
|
||
|
||
if [ "$pid" = "$$" ] || [ "$pid" = "$PPID" ]; then
|
||
return 1
|
||
fi
|
||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||
|
||
local stat
|
||
stat=$(ps -o stat= -p "$pid" 2>/dev/null | awk '{print $1}')
|
||
if [[ "$stat" == Z* ]]; then
|
||
return 1
|
||
fi
|
||
|
||
local cmdline
|
||
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
|
||
[ -n "$cmdline" ] || return 1
|
||
|
||
# Never target the installer/uninstaller themselves
|
||
if [[ "$cmdline" == *"install.sh"* || "$cmdline" == *"uninstall.sh"* ]]; then
|
||
return 1
|
||
fi
|
||
|
||
# python -m vocalinux.main [--flags]
|
||
if [[ "$cmdline" == *"-m vocalinux.main"* ]]; then
|
||
return 0
|
||
fi
|
||
|
||
# IBus engine: .../vocalinux/.../ibus_engine.py
|
||
if [[ "$cmdline" == *"ibus_engine.py"* && "$cmdline" == *"vocalinux"* ]]; then
|
||
return 0
|
||
fi
|
||
|
||
# Console-script entrypoints. Setuptools scripts are executed as
|
||
# `python …/bin/vocalinux`, so argv0 is python — scan every arg for the
|
||
# script path (require …/bin/vocalinux to avoid matching a repo directory).
|
||
local arg
|
||
while IFS= read -r -d '' arg; do
|
||
case "$arg" in
|
||
vocalinux|vocalinux-gui|*/bin/vocalinux|*/bin/vocalinux-gui)
|
||
return 0
|
||
;;
|
||
esac
|
||
done < "/proc/$pid/cmdline"
|
||
|
||
return 1
|
||
}
|
||
|
||
# Prefer the PIDs the app writes; fall back to narrow entrypoint patterns only.
|
||
# Deliberately does NOT use `pgrep -f vocalinux` (matches editors/shells/cwd paths).
|
||
get_vocalinux_pids() {
|
||
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||
local -A seen=()
|
||
local pid candidate
|
||
local -a candidates=()
|
||
|
||
for candidate in \
|
||
"$(read_pid_file "$data_home/vocalinux/instance.lock")" \
|
||
"$(read_pid_file "$data_home/vocalinux-ibus/engine.pid")"
|
||
do
|
||
[ -n "$candidate" ] && candidates+=("$candidate")
|
||
done
|
||
|
||
# Fallback when PID files are missing/stale: exact process name or known argv.
|
||
while read -r candidate; do
|
||
[ -n "$candidate" ] && candidates+=("$candidate")
|
||
done < <(
|
||
pgrep -u "$(id -u)" -x vocalinux 2>/dev/null || true
|
||
pgrep -u "$(id -u)" -x vocalinux-gui 2>/dev/null || true
|
||
pgrep -u "$(id -u)" -f -- '-m vocalinux\.main' 2>/dev/null || true
|
||
pgrep -u "$(id -u)" -f -- 'vocalinux/.*/ibus_engine\.py' 2>/dev/null || true
|
||
)
|
||
|
||
for pid in "${candidates[@]}"; do
|
||
[ -n "${seen[$pid]:-}" ] && continue
|
||
seen[$pid]=1
|
||
if is_vocalinux_process "$pid"; then
|
||
echo "$pid"
|
||
fi
|
||
done
|
||
}
|
||
|
||
check_running_processes() {
|
||
local PIDS
|
||
PIDS=$(get_vocalinux_pids || true)
|
||
|
||
if [ -n "$PIDS" ]; then
|
||
print_warning "Found running Vocalinux process(es):"
|
||
local pid
|
||
for pid in $PIDS; do
|
||
local cmd
|
||
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || echo "?")
|
||
print_warning " PID $pid: $cmd"
|
||
done
|
||
echo ""
|
||
|
||
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
|
||
print_info "Non-interactive mode: stopping Vocalinux automatically..."
|
||
else
|
||
read_prompt -p "Vocalinux must be stopped before installation. Kill running process(es)? (Y/n) " -n 1 -r
|
||
echo
|
||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||
print_error "Cannot proceed with installation while Vocalinux is running."
|
||
print_info "Please stop Vocalinux manually and run the installer again."
|
||
exit "$EXIT_USER_ABORT"
|
||
fi
|
||
fi
|
||
|
||
print_info "Stopping Vocalinux..."
|
||
# shellcheck disable=SC2086
|
||
echo $PIDS | xargs -r kill -TERM 2>/dev/null || true
|
||
sleep 2
|
||
|
||
local REMAINING_PIDS
|
||
REMAINING_PIDS=$(get_vocalinux_pids || true)
|
||
|
||
if [ -n "$REMAINING_PIDS" ]; then
|
||
print_warning "Some processes still running, forcing termination..."
|
||
# shellcheck disable=SC2086
|
||
echo $REMAINING_PIDS | xargs -r kill -KILL 2>/dev/null || true
|
||
sleep 1
|
||
fi
|
||
|
||
local FINAL_PIDS
|
||
FINAL_PIDS=$(get_vocalinux_pids || true)
|
||
if [ -n "$FINAL_PIDS" ]; then
|
||
print_error "Could not terminate all Vocalinux processes: $FINAL_PIDS"
|
||
print_error "Please manually kill these processes and run the installer again."
|
||
exit "$EXIT_USER_ABORT"
|
||
else
|
||
print_success "All Vocalinux processes stopped"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# Parse command line arguments
|
||
INSTALL_MODE="user"
|
||
RUN_TESTS="no"
|
||
DEV_MODE="no"
|
||
VENV_DIR="venv"
|
||
SKIP_MODELS="no"
|
||
SKIP_SYSTEM_DEPS="no"
|
||
NON_INTERACTIVE="no"
|
||
INTERACTIVE_MODE="yes" # Default to interactive mode
|
||
AUTO_MODE="no"
|
||
UPGRADE_MODE="no"
|
||
REBUILD_WHISPERCPP="ask"
|
||
# Pinned pywhispercpp release (sdist, built from source). Keep in sync with
|
||
# uv.lock and the requirements/ exports.
|
||
PYWHISPERCPP_VERSION="1.5.0"
|
||
HAS_NVIDIA_GPU="unknown"
|
||
GPU_NAME=""
|
||
GPU_MEMORY=""
|
||
HAS_VULKAN="no"
|
||
VULKAN_DEVICE=""
|
||
VULKAN_SOFTWARE_DEVICE=""
|
||
# Initialize mode/state variables that are set later by flags or prompts so
|
||
# that every read under `set -u` is well-defined in every code path.
|
||
INSTALL_TAG=""
|
||
SELECTED_ENGINE=""
|
||
WHISPERCPP_BACKEND=""
|
||
WHISPERCPP_ALREADY_INSTALLED="false"
|
||
REMOTE_API_URL=""
|
||
|
||
# Detect if running non-interactively (e.g., via curl | bash).
|
||
# stdin keeps carrying the script text to bash, so it must stay on the pipe:
|
||
# redirecting fd 0 here closes the pipe mid-download (curl error 23) and makes
|
||
# bash read the rest of the installer from the terminal as commands. Prompts
|
||
# reach the user through /dev/tty instead (see read_prompt). With no terminal
|
||
# at all (headless/CI), fall back to automatic mode.
|
||
TERMINAL_PROMPTS="no"
|
||
if [ -t 0 ]; then
|
||
TERMINAL_PROMPTS="yes"
|
||
elif [ -e /dev/tty ] && [ -r /dev/tty ] && { true < /dev/tty; } 2>/dev/null; then
|
||
TERMINAL_PROMPTS="yes"
|
||
INTERACTIVE_MODE="ask"
|
||
else
|
||
AUTO_MODE="yes"
|
||
INTERACTIVE_MODE="no"
|
||
NON_INTERACTIVE="yes"
|
||
fi
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
--dev)
|
||
DEV_MODE="yes"
|
||
shift
|
||
;;
|
||
--test)
|
||
RUN_TESTS="yes"
|
||
shift
|
||
;;
|
||
--venv-dir=*)
|
||
VENV_DIR="${1#*=}"
|
||
shift
|
||
;;
|
||
--skip-models)
|
||
SKIP_MODELS="yes"
|
||
shift
|
||
;;
|
||
--skip-system-deps)
|
||
SKIP_SYSTEM_DEPS="yes"
|
||
shift
|
||
;;
|
||
--rebuild-whispercpp)
|
||
REBUILD_WHISPERCPP="yes"
|
||
shift
|
||
;;
|
||
--no-rebuild-whispercpp)
|
||
REBUILD_WHISPERCPP="no"
|
||
shift
|
||
;;
|
||
--engine=*)
|
||
SELECTED_ENGINE="${1#*=}"
|
||
shift
|
||
;;
|
||
--interactive|-i)
|
||
INTERACTIVE_MODE="yes"
|
||
shift
|
||
;;
|
||
--tag=*)
|
||
INSTALL_TAG="${1#*=}"
|
||
shift
|
||
;;
|
||
--auto)
|
||
AUTO_MODE="yes"
|
||
INTERACTIVE_MODE="no"
|
||
NON_INTERACTIVE="yes"
|
||
shift
|
||
;;
|
||
--upgrade)
|
||
UPGRADE_MODE="yes"
|
||
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 " --upgrade Upgrade mode - update an existing install, keep its settings"
|
||
echo " --auto --engine=whisper Auto mode with specific engine"
|
||
echo ""
|
||
echo "Options:"
|
||
echo " --interactive, -i Force interactive mode (default)"
|
||
echo " --auto Non-interactive automatic installation"
|
||
echo " --upgrade Non-interactive upgrade of an existing install: reuse the"
|
||
echo " venv, update the configured engine's dependencies, never"
|
||
echo " modify config.json"
|
||
echo " --engine=NAME Speech engine: whisper_cpp (default), whisper, vosk, parakeet, faster_whisper, remote_api"
|
||
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"
|
||
echo " --skip-models Skip downloading speech models during installation"
|
||
echo " --skip-system-deps"
|
||
echo " Skip package-manager dependency installation (advanced)"
|
||
echo " --rebuild-whispercpp Rebuild/reinstall pywhispercpp even if already installed"
|
||
echo " --no-rebuild-whispercpp Reuse existing pywhispercpp when present (auto-mode default)"
|
||
echo " --tag=TAG Install specific release tag (default: latest release)"
|
||
echo " --help Show this help message"
|
||
echo ""
|
||
echo "Examples:"
|
||
echo " $0 # Interactive mode (recommended)"
|
||
echo " $0 --upgrade # Update an existing install, keep settings"
|
||
echo " $0 --auto # Auto-install with whisper.cpp"
|
||
echo " $0 --auto --engine=vosk # Auto-install VOSK only"
|
||
echo " $0 --auto --engine=faster_whisper # Auto-install faster-whisper"
|
||
echo " $0 --dev --test # Dev mode with tests"
|
||
echo ""
|
||
echo "During installation a full transcript is saved to"
|
||
echo " ~/.local/state/vocalinux/install-<timestamp>.log"
|
||
echo ""
|
||
echo "Exit codes:"
|
||
echo " 0 success"
|
||
echo " 1 generic failure"
|
||
echo " 2 required system dependencies could not be installed"
|
||
echo " 3 network / download failure"
|
||
echo " 4 user aborted at a prompt"
|
||
exit 0
|
||
;;
|
||
*)
|
||
print_error "Unknown option: $1"
|
||
echo "Use --help to see available options"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# If dev mode is enabled, automatically run tests
|
||
if [[ "$DEV_MODE" == "yes" ]]; then
|
||
RUN_TESTS="yes"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Global install log: everything (stdout + stderr) is teed to this file so
|
||
# failures can be debugged after the fact. The path is printed at exit.
|
||
# ---------------------------------------------------------------------------
|
||
INSTALL_LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/vocalinux"
|
||
if ! mkdir -p "$INSTALL_LOG_DIR" 2>/dev/null; then
|
||
INSTALL_LOG_DIR="${TMPDIR:-/tmp}"
|
||
fi
|
||
INSTALL_LOG_FILE="$INSTALL_LOG_DIR/install-$(date +%Y%m%d-%H%M%S).log"
|
||
exec > >(tee -a "$INSTALL_LOG_FILE") 2>&1
|
||
|
||
# Scratch directory for pip logs, model downloads, and other temps.
|
||
# Removed on success; kept for inspection (with a notice) when the install fails.
|
||
# Exporting TMPDIR routes pip/wget/curl scratch files into this directory.
|
||
VOCALINUX_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vocalinux-install.XXXXXXXX")"
|
||
export TMPDIR="$VOCALINUX_TMP_DIR"
|
||
|
||
cleanup_on_exit() {
|
||
local rc=$?
|
||
if [ "$rc" -eq "$EXIT_OK" ]; then
|
||
rm -rf "$VOCALINUX_TMP_DIR"
|
||
else
|
||
print_error ""
|
||
print_error "Installation did not complete (exit code $rc)."
|
||
print_error "Full install log: $INSTALL_LOG_FILE"
|
||
print_error "Scratch files kept for inspection: $VOCALINUX_TMP_DIR"
|
||
fi
|
||
}
|
||
trap cleanup_on_exit EXIT
|
||
trap 'print_error "Unexpected error near line $LINENO (exit code $?); see $INSTALL_LOG_FILE"' ERR
|
||
trap 'print_warning "Interrupted by user"; exit 130' INT
|
||
trap 'print_warning "Terminated by signal"; exit 143' TERM
|
||
|
||
# Display ASCII art banner
|
||
cat << "EOF"
|
||
|
||
▗▖ ▗▖ ▗▄▖ ▗▄▄▖ ▗▄▖ ▗▖ ▗▄▄▄▖▗▖ ▗▖▗▖ ▗▖▗▖ ▗▖
|
||
▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ ▐▌▐▌ █ ▐▛▚▖▐▌▐▌ ▐▌ ▝▚▞▘
|
||
▐▌ ▐▌▐▌ ▐▌▐▌ ▐▛▀▜▌▐▌ █ ▐▌ ▝▜▌▐▌ ▐▌ ▐▌
|
||
▝▚▞▘ ▝▚▄▞▘▝▚▄▄▖▐▌ ▐▌▐▙▄▄▖▗▄█▄▖▐▌ ▐▌▝▚▄▞▘▗▞▘▝▚▖
|
||
|
||
Voice Dictation for Linux
|
||
|
||
EOF
|
||
|
||
print_info "Vocalinux Installer"
|
||
print_info "=============================="
|
||
echo ""
|
||
|
||
check_running_processes
|
||
|
||
resolve_install_tag() {
|
||
if [ -n "$INSTALL_TAG" ]; then
|
||
return 0
|
||
fi
|
||
if command_exists curl; then
|
||
local latest
|
||
# (|| true: pipefail would otherwise abort when head closes the pipe
|
||
# early or the API is unreachable; empty means "not resolved")
|
||
latest=$(curl -fsSL --connect-timeout 5 --retry 2 \
|
||
"https://api.github.com/repos/VocaHQ/vocalinux/releases/latest" \
|
||
2>/dev/null | grep '"tag_name"' | head -1 | cut -d'"' -f4 || true)
|
||
if [ -n "$latest" ]; then
|
||
INSTALL_TAG="$latest"
|
||
return 0
|
||
fi
|
||
fi
|
||
# No hardcoded fallback tag: silently installing a stale release is worse
|
||
# than failing. The remote-install path below aborts with a --tag hint if
|
||
# INSTALL_TAG is still empty; in-repo installs do not need a tag at all.
|
||
print_warning "Could not determine the latest release tag (GitHub API unreachable?)."
|
||
return 0
|
||
}
|
||
|
||
resolve_install_tag
|
||
|
||
# Check if running from within the vocalinux repo or remotely (via curl)
|
||
# VOCALINUX_REPO_URL overrides the clone source so the remote-install gate can
|
||
# serve a tag from a local mirror; public installs use the repository below.
|
||
REPO_URL="${VOCALINUX_REPO_URL:-https://github.com/VocaHQ/vocalinux.git}"
|
||
INSTALL_DIR=""
|
||
CLEANUP_ON_EXIT="${VOCALINUX_REMOTE_INSTALL:-no}"
|
||
|
||
handoff_to_tagged_installer() {
|
||
local tagged_installer="$INSTALL_DIR/install.sh"
|
||
local remote_venv="$HOME/.local/share/vocalinux/venv"
|
||
|
||
if grep -q 'CLEANUP_ON_EXIT="${VOCALINUX_REMOTE_INSTALL:-no}"' "$tagged_installer"; then
|
||
export VOCALINUX_REMOTE_INSTALL=yes
|
||
export TMPDIR="$(dirname "$VOCALINUX_TMP_DIR")"
|
||
rmdir "$VOCALINUX_TMP_DIR"
|
||
# A bootstrap read from a pipe (curl | bash) leaves its unread tail on
|
||
# stdin: bash hands the pipe to the tagged installer, which reads its
|
||
# commands from a file instead, so nothing drains it. A producer still
|
||
# streaming (the download is bigger than the pipe buffer) stays blocked
|
||
# for the whole install and reports curl error 23 when this fd closes.
|
||
# Drain to EOF first; a tty has no EOF to wait for and is left alone.
|
||
if [ ! -t 0 ]; then
|
||
cat >/dev/null 2>&1 || true
|
||
fi
|
||
exec bash "$tagged_installer" "${INSTALLER_ARGS[@]}" "--venv-dir=$remote_venv"
|
||
fi
|
||
|
||
bash "$tagged_installer" "${INSTALLER_ARGS[@]}" "--venv-dir=$remote_venv" || return $?
|
||
if [ ! -f "$INSTALL_DIR/activate-vocalinux.sh" ]; then
|
||
print_error "The tagged installer did not create activate-vocalinux.sh."
|
||
return 1
|
||
fi
|
||
mkdir -p "$HOME/.local/bin"
|
||
mv "$INSTALL_DIR/activate-vocalinux.sh" "$HOME/.local/bin/activate-vocalinux.sh"
|
||
}
|
||
|
||
# Function to check and install git if needed
|
||
ensure_git_installed() {
|
||
if command -v git >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
print_warning "git is not installed. Attempting to install git..."
|
||
|
||
# Detect distribution for package manager selection
|
||
local DISTRO_FAMILY="unknown"
|
||
if [ -f /etc/os-release ]; then
|
||
. /etc/os-release
|
||
if [[ "$ID" == "ubuntu" || "${ID_LIKE:-}" == *"ubuntu"* || "$ID" == "pop" || "$ID" == "linuxmint" || "$ID" == "elementary" || "$ID" == "zorin" ]]; then
|
||
DISTRO_FAMILY="ubuntu"
|
||
elif [[ "$ID" == "debian" || "${ID_LIKE:-}" == *"debian"* ]]; then
|
||
DISTRO_FAMILY="debian"
|
||
elif [[ "$ID" == "fedora" || "${ID_LIKE:-}" == *"fedora"* || "$ID" == "rhel" || "$ID" == "centos" || "$ID" == "rocky" || "$ID" == "almalinux" ]]; then
|
||
DISTRO_FAMILY="fedora"
|
||
elif [[ "$ID" == "arch" || "${ID_LIKE:-}" == *"arch"* || "$ID" == "manjaro" || "$ID" == "endeavouros" ]]; then
|
||
DISTRO_FAMILY="arch"
|
||
elif [[ "$ID" == "opensuse" || "${ID_LIKE:-}" == *"suse"* ]]; then
|
||
DISTRO_FAMILY="suse"
|
||
elif [[ "$ID" == "gentoo" ]]; then
|
||
DISTRO_FAMILY="gentoo"
|
||
elif [[ "$ID" == "alpine" ]]; then
|
||
DISTRO_FAMILY="alpine"
|
||
elif [[ "$ID" == "void" ]]; then
|
||
DISTRO_FAMILY="void"
|
||
elif [[ "$ID" == "solus" ]]; then
|
||
DISTRO_FAMILY="solus"
|
||
elif [[ "$ID" == "mageia" ]]; then
|
||
DISTRO_FAMILY="mageia"
|
||
fi
|
||
fi
|
||
|
||
case "$DISTRO_FAMILY" in
|
||
ubuntu|debian)
|
||
sudo apt update && sudo apt install -y git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Ubuntu/Debian: sudo apt install git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
fedora)
|
||
sudo dnf install -y git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Fedora: sudo dnf install git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
arch)
|
||
sudo pacman -S --noconfirm git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Arch: sudo pacman -S git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
suse)
|
||
sudo zypper install -y git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " openSUSE: sudo zypper install git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
gentoo)
|
||
sudo emerge git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Gentoo: sudo emerge git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
alpine)
|
||
sudo apk add git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Alpine: sudo apk add git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
void)
|
||
sudo xbps-install -Sy git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Void: sudo xbps-install -Sy git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
solus)
|
||
sudo eopkg install git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
print_error " Solus: sudo eopkg install git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
;;
|
||
mageia)
|
||
if command -v dnf >/dev/null 2>&1; then
|
||
sudo dnf install -y git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
else
|
||
sudo urpmi --force git || {
|
||
print_error "Failed to install git. Please install git manually and run the installer again."
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
fi
|
||
;;
|
||
*)
|
||
print_error "git is not installed and could not auto-detect your distribution."
|
||
print_error "Please install git manually and run the installer again:"
|
||
print_error " Ubuntu/Debian: sudo apt install git"
|
||
print_error " Fedora/RHEL: sudo dnf install git"
|
||
print_error " Arch: sudo pacman -S git"
|
||
print_error " openSUSE: sudo zypper install git"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
;;
|
||
esac
|
||
|
||
print_success "git installed successfully!"
|
||
}
|
||
|
||
# Check if running from within the vocalinux repository
|
||
# Validate that pyproject.toml/setup.py actually belongs to vocalinux before entering local repo mode
|
||
IS_VOCALINUX_LOCAL=false
|
||
if [ -f "pyproject.toml" ] && grep -q 'name = "vocalinux"' "pyproject.toml" 2>/dev/null; then
|
||
IS_VOCALINUX_LOCAL=true
|
||
elif [ -f "setup.py" ] && grep -q "vocalinux" "setup.py" 2>/dev/null; then
|
||
IS_VOCALINUX_LOCAL=true
|
||
fi
|
||
|
||
if [ "$IS_VOCALINUX_LOCAL" = true ]; then
|
||
# Running from within the vocalinux repo
|
||
INSTALL_DIR="$(pwd)"
|
||
print_info "Running from local repository: $INSTALL_DIR"
|
||
# Convert relative VENV_DIR to absolute for wrapper scripts.
|
||
# Absolute --venv-dir= paths must not be re-prefixed with INSTALL_DIR.
|
||
case "$VENV_DIR" in
|
||
/*) ;;
|
||
*) VENV_DIR="$INSTALL_DIR/$VENV_DIR" ;;
|
||
esac
|
||
else
|
||
# Running remotely (e.g., via curl | bash)
|
||
if [ -z "$INSTALL_TAG" ]; then
|
||
print_error "No release tag available: the GitHub API was unreachable and no --tag was given."
|
||
print_error "Re-run with an explicit release tag, e.g.: --tag=<release>"
|
||
exit "$EXIT_NETWORK"
|
||
fi
|
||
print_info "Installing Vocalinux version: ${INSTALL_TAG}"
|
||
|
||
# Ensure git is installed before attempting to clone
|
||
ensure_git_installed
|
||
|
||
INSTALL_DIR="$HOME/.local/share/vocalinux-install"
|
||
mkdir -p "$INSTALL_DIR"
|
||
|
||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||
print_info "Updating existing clone..."
|
||
cd "$INSTALL_DIR"
|
||
if ! git fetch origin tag "$INSTALL_TAG" || ! git reset --hard "$INSTALL_TAG"; then
|
||
print_error "Failed to update the Vocalinux clone to $INSTALL_TAG."
|
||
print_error "Check the install log and your network connection: $INSTALL_LOG_FILE"
|
||
exit "$EXIT_NETWORK"
|
||
fi
|
||
else
|
||
rm -rf "$INSTALL_DIR"
|
||
git clone --depth 1 --branch "$INSTALL_TAG" "$REPO_URL" "$INSTALL_DIR" || {
|
||
print_error "Failed to clone Vocalinux repository"
|
||
exit "$EXIT_NETWORK"
|
||
}
|
||
cd "$INSTALL_DIR"
|
||
fi
|
||
CLEANUP_ON_EXIT="yes"
|
||
print_info "Repository cloned to: $INSTALL_DIR"
|
||
|
||
# Keep the installer and exports on the selected tag. Legacy tags do not
|
||
# understand the remote marker, so the handoff relocates their helper.
|
||
handoff_to_tagged_installer
|
||
exit "$EXIT_OK"
|
||
fi
|
||
|
||
# Change to install directory
|
||
cd "$INSTALL_DIR"
|
||
|
||
# The bootstrap above must stay self-contained because it is also served by the
|
||
# curl installer. Once the local or tagged repository is available, load the
|
||
# implementation modules from that same revision.
|
||
source_installer_module() {
|
||
local module="$INSTALL_DIR/install.d/$1"
|
||
if [ ! -r "$module" ]; then
|
||
print_error "Installer module is missing or unreadable: $module"
|
||
return 1
|
||
fi
|
||
# shellcheck source=/dev/null
|
||
source "$module"
|
||
}
|
||
|
||
# Shared by the model installer module and the runtime downloader.
|
||
MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt"
|
||
|
||
for INSTALLER_MODULE in package_map.sh interactive.sh system_dependencies.sh models.sh desktop.sh; do
|
||
source_installer_module "$INSTALLER_MODULE" || exit 1
|
||
done
|
||
unset INSTALLER_MODULE
|
||
|
||
print_info "Using virtual environment: $VENV_DIR"
|
||
[[ "$DEV_MODE" == "yes" ]] && print_info "Installing in development mode"
|
||
[[ "$RUN_TESTS" == "yes" ]] && print_info "Tests will be run after installation"
|
||
echo ""
|
||
|
||
# Check if running as root
|
||
if [ "$EUID" -eq 0 ]; then
|
||
print_error "Please do not run this script as root or with sudo."
|
||
exit 1
|
||
fi
|
||
|
||
# Detect Linux distribution and version
|
||
detect_distro() {
|
||
if [ -f /etc/os-release ]; then
|
||
. /etc/os-release
|
||
DISTRO_NAME="${NAME:-unknown}"
|
||
DISTRO_ID="${ID:-unknown}"
|
||
DISTRO_VERSION="${VERSION_ID:-}"
|
||
DISTRO_FAMILY="unknown"
|
||
|
||
# Determine distribution family
|
||
if [[ "$ID" == "ubuntu" || "${ID_LIKE:-}" == *"ubuntu"* || "$ID" == "pop" || "$ID" == "linuxmint" || "$ID" == "elementary" || "$ID" == "zorin" ]]; then
|
||
DISTRO_FAMILY="ubuntu"
|
||
elif [[ "$ID" == "debian" || "${ID_LIKE:-}" == *"debian"* ]]; then
|
||
DISTRO_FAMILY="debian"
|
||
elif [[ "$ID" == "fedora" || "${ID_LIKE:-}" == *"fedora"* || "$ID" == "rhel" || "$ID" == "centos" || "$ID" == "rocky" || "$ID" == "almalinux" ]]; then
|
||
DISTRO_FAMILY="fedora"
|
||
elif [[ "$ID" == "arch" || "${ID_LIKE:-}" == *"arch"* || "$ID" == "manjaro" || "$ID" == "endeavouros" ]]; then
|
||
DISTRO_FAMILY="arch"
|
||
elif [[ "$ID" == "opensuse" || "${ID_LIKE:-}" == *"suse"* ]]; then
|
||
DISTRO_FAMILY="suse"
|
||
elif [[ "$ID" == "gentoo" ]]; then
|
||
DISTRO_FAMILY="gentoo"
|
||
elif [[ "$ID" == "alpine" ]]; then
|
||
DISTRO_FAMILY="alpine"
|
||
elif [[ "$ID" == "void" ]]; then
|
||
DISTRO_FAMILY="void"
|
||
elif [[ "$ID" == "solus" ]]; then
|
||
DISTRO_FAMILY="solus"
|
||
elif [[ "$ID" == "mageia" ]]; then
|
||
DISTRO_FAMILY="mageia"
|
||
fi
|
||
|
||
print_info "Detected: $DISTRO_NAME $DISTRO_VERSION ($DISTRO_FAMILY family)"
|
||
return 0
|
||
else
|
||
print_error "Could not detect Linux distribution (missing /etc/os-release)"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Detect NVIDIA GPU presence
|
||
detect_nvidia_gpu() {
|
||
# Check if nvidia-smi command exists and can successfully query GPU
|
||
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
|
||
# Extract GPU information for user feedback
|
||
# (|| true: nvidia-smi may fail mid-pipeline; pipefail must not abort)
|
||
GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -n1 || true)
|
||
GPU_MEMORY=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader 2>/dev/null | head -n1 || true)
|
||
HAS_NVIDIA_GPU="yes"
|
||
return 0
|
||
else
|
||
HAS_NVIDIA_GPU="no"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
cuda_toolkit_root_has_runtime_library() {
|
||
local CUDA_ROOT="$1"
|
||
|
||
compgen -G "$CUDA_ROOT/lib64/libcudart.so*" >/dev/null && return 0
|
||
compgen -G "$CUDA_ROOT/lib/libcudart.so*" >/dev/null && return 0
|
||
compgen -G "$CUDA_ROOT/lib/x86_64-linux-gnu/libcudart.so*" >/dev/null && return 0
|
||
compgen -G "$CUDA_ROOT/targets/x86_64-linux/lib/libcudart.so*" >/dev/null && return 0
|
||
compgen -G "$CUDA_ROOT/targets/aarch64-linux/lib/libcudart.so*" >/dev/null && return 0
|
||
compgen -G "$CUDA_ROOT/targets/sbsa-linux/lib/libcudart.so*" >/dev/null && return 0
|
||
return 1
|
||
}
|
||
|
||
validate_cuda_toolkit_root() {
|
||
local CUDA_ROOT="$1"
|
||
|
||
[ -n "$CUDA_ROOT" ] || return 1
|
||
[ -x "$CUDA_ROOT/bin/nvcc" ] || return 1
|
||
[ -f "$CUDA_ROOT/include/cuda_runtime.h" ] || return 1
|
||
cuda_toolkit_root_has_runtime_library "$CUDA_ROOT" || return 1
|
||
}
|
||
|
||
candidate_cuda_toolkit_roots() {
|
||
local CUDA_VAR
|
||
for CUDA_VAR in CUDAToolkit_ROOT CUDA_HOME CUDA_PATH; do
|
||
local CUDA_ROOT="${!CUDA_VAR:-}"
|
||
[ -n "$CUDA_ROOT" ] && printf '%s\n' "$CUDA_ROOT"
|
||
done
|
||
|
||
if command -v nvcc >/dev/null 2>&1; then
|
||
local NVCC_PATH
|
||
local NVCC_ROOT
|
||
NVCC_PATH=$(readlink -f "$(command -v nvcc)" 2>/dev/null || command -v nvcc)
|
||
NVCC_ROOT=$(cd "$(dirname "$NVCC_PATH")/.." 2>/dev/null && pwd -P)
|
||
[ -n "$NVCC_ROOT" ] && printf '%s\n' "$NVCC_ROOT"
|
||
fi
|
||
|
||
local CUDA_ROOT
|
||
for CUDA_ROOT in /usr/local/cuda /usr/local/cuda-* /opt/cuda; do
|
||
[ -d "$CUDA_ROOT" ] && printf '%s\n' "$CUDA_ROOT"
|
||
done
|
||
}
|
||
|
||
find_valid_cuda_toolkit_root() {
|
||
local QUIET="${1:-no}"
|
||
local SEEN_ROOTS=""
|
||
local CUDA_ROOT
|
||
|
||
while IFS= read -r CUDA_ROOT; do
|
||
[ -n "$CUDA_ROOT" ] || continue
|
||
|
||
local NORMALIZED_ROOT
|
||
NORMALIZED_ROOT=$(cd "$CUDA_ROOT" 2>/dev/null && pwd -P) || NORMALIZED_ROOT="$CUDA_ROOT"
|
||
|
||
if [[ ":$SEEN_ROOTS:" == *":$NORMALIZED_ROOT:"* ]]; then
|
||
continue
|
||
fi
|
||
SEEN_ROOTS="${SEEN_ROOTS:+$SEEN_ROOTS:}$NORMALIZED_ROOT"
|
||
|
||
if validate_cuda_toolkit_root "$NORMALIZED_ROOT"; then
|
||
printf '%s\n' "$NORMALIZED_ROOT"
|
||
return 0
|
||
fi
|
||
|
||
if [[ "$QUIET" != "quiet" ]]; then
|
||
print_warning "Ignoring incomplete CUDA toolkit root: $NORMALIZED_ROOT" >&2
|
||
print_warning " Required: bin/nvcc, include/cuda_runtime.h, and libcudart.so*" >&2
|
||
fi
|
||
done < <(candidate_cuda_toolkit_roots)
|
||
|
||
return 1
|
||
}
|
||
|
||
detect_nvidia_compute_architectures() {
|
||
command -v nvidia-smi >/dev/null 2>&1 || return 1
|
||
|
||
local COMPUTE_CAPS
|
||
COMPUTE_CAPS=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)
|
||
[ -n "$COMPUTE_CAPS" ] || return 1
|
||
|
||
printf '%s\n' "$COMPUTE_CAPS" | awk '
|
||
{
|
||
gsub(/[[:space:]]/, "", $1)
|
||
if ($1 ~ /^[0-9]+(\.[0-9]+)?$/) {
|
||
split($1, parts, ".")
|
||
minor = parts[2]
|
||
if (minor == "") {
|
||
minor = "0"
|
||
}
|
||
print parts[1] minor "-real"
|
||
}
|
||
}
|
||
' | sort -u | paste -sd ';' -
|
||
}
|
||
|
||
cuda_toolkit_supports_architectures() {
|
||
local CUDA_ROOT="$1"
|
||
local CUDA_ARCHS="$2"
|
||
[ -n "$CUDA_ARCHS" ] || return 0
|
||
|
||
local NVCC_ARCHS
|
||
NVCC_ARCHS=$("$CUDA_ROOT/bin/nvcc" --list-gpu-arch 2>/dev/null || true)
|
||
if [ -z "$NVCC_ARCHS" ]; then
|
||
print_warning "Could not query supported CUDA architectures from $CUDA_ROOT/bin/nvcc" >&2
|
||
print_warning "Continuing with detected CMAKE_CUDA_ARCHITECTURES=$CUDA_ARCHS" >&2
|
||
return 0
|
||
fi
|
||
|
||
local IFS=';'
|
||
local CUDA_ARCH
|
||
for CUDA_ARCH in $CUDA_ARCHS; do
|
||
local ARCH_DIGITS="${CUDA_ARCH%%-*}"
|
||
if ! printf '%s\n' "$NVCC_ARCHS" | grep -q "compute_$ARCH_DIGITS"; then
|
||
print_warning "CUDA toolkit at $CUDA_ROOT cannot target compute capability $ARCH_DIGITS." >&2
|
||
print_warning "Install a newer CUDA toolkit, such as CUDA 11.8+ for RTX 40/Ada GPUs." >&2
|
||
return 1
|
||
fi
|
||
done
|
||
|
||
return 0
|
||
}
|
||
|
||
get_cuda_cmake_args() {
|
||
local CUDA_ROOT="$1"
|
||
local CUDA_ARGS="-DCUDAToolkit_ROOT=$CUDA_ROOT -DCMAKE_CUDA_COMPILER=$CUDA_ROOT/bin/nvcc"
|
||
local CUDA_ARCHS
|
||
|
||
CUDA_ARCHS=$(detect_nvidia_compute_architectures || true)
|
||
if [ -n "$CUDA_ARCHS" ]; then
|
||
cuda_toolkit_supports_architectures "$CUDA_ROOT" "$CUDA_ARCHS" || return 1
|
||
CUDA_ARGS="$CUDA_ARGS -DCMAKE_CUDA_ARCHITECTURES=$CUDA_ARCHS"
|
||
fi
|
||
|
||
printf '%s\n' "$CUDA_ARGS"
|
||
}
|
||
|
||
# CPU implementations of Vulkan. whisper.cpp on these is slower than its own CPU
|
||
# backend, so they must never be reported as a GPU. One list, read by both
|
||
# detect_vulkan and check_vulkan_gpu_compatibility.
|
||
VULKAN_SOFTWARE_PATTERNS=(llvmpipe swiftshader lavapipe zink virtio venus)
|
||
|
||
is_software_renderer() {
|
||
local name="$1" pattern
|
||
for pattern in "${VULKAN_SOFTWARE_PATTERNS[@]}"; do
|
||
if printf '%s' "$name" | grep -iq "$pattern"; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# List the Vulkan devices vulkaninfo reports, one per line.
|
||
vulkan_device_names() {
|
||
command -v vulkaninfo >/dev/null 2>&1 || return 1
|
||
# (|| true: no match must not abort pipefail.)
|
||
vulkaninfo --summary 2>/dev/null | awk -F'=' '/deviceName/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 != "") print $2}' || true
|
||
}
|
||
|
||
# Detect Vulkan support for whisper.cpp
|
||
detect_vulkan() {
|
||
# Only a hardware deviceName counts. The loader prints a header even when no
|
||
# ICD resolves, and a software renderer is not a GPU -- reporting either as
|
||
# one is what made Step 1 promise Vulkan performance the machine cannot give.
|
||
HAS_VULKAN="no"
|
||
VULKAN_DEVICE=""
|
||
VULKAN_SOFTWARE_DEVICE=""
|
||
|
||
local devices name
|
||
devices=$(vulkan_device_names) || return 1
|
||
[ -n "$devices" ] || return 1
|
||
|
||
while IFS= read -r name; do
|
||
[ -z "$name" ] && continue
|
||
if is_software_renderer "$name"; then
|
||
[ -n "$VULKAN_SOFTWARE_DEVICE" ] || VULKAN_SOFTWARE_DEVICE="$name"
|
||
continue
|
||
fi
|
||
HAS_VULKAN="yes"
|
||
VULKAN_DEVICE="$name"
|
||
return 0
|
||
done <<< "$devices"
|
||
|
||
return 1
|
||
}
|
||
|
||
# Check for incompatible Intel GPUs that don't support VK_KHR_16bit_storage
|
||
# These GPUs will fail with "device does not support 16-bit storage" error
|
||
# Affected: Intel Gen7 and older (Ivy Bridge, Haswell, Sandy Bridge)
|
||
# See: https://github.com/VocaHQ/vocalinux/issues/238
|
||
#
|
||
# IMPORTANT: This check filters out software renderers (llvmpipe, etc.) and only
|
||
# evaluates real hardware GPUs. Modern AMD, Intel (Gen8+), and NVIDIA GPUs all
|
||
# support VK_KHR_16bit_storage, so this mainly catches very old Intel Gen7 GPUs.
|
||
check_vulkan_gpu_compatibility() {
|
||
# List of known incompatible GPU patterns (old Intel Gen7 and older)
|
||
local INCOMPATIBLE_PATTERNS=(
|
||
"Ivy Bridge"
|
||
"Haswell"
|
||
"Sandy Bridge"
|
||
"HD Graphics 2500"
|
||
"HD Graphics 4000"
|
||
"HD Graphics 4400"
|
||
"HD Graphics 4600"
|
||
"HD Graphics P4600"
|
||
"HD Graphics P4700"
|
||
"IVB"
|
||
"HSW"
|
||
"SNB"
|
||
)
|
||
|
||
# Check if vulkaninfo is available
|
||
if ! command -v vulkaninfo >/dev/null 2>&1; then
|
||
echo "unknown:vulkaninfo not available"
|
||
return 1
|
||
fi
|
||
|
||
# Get all device names from vulkaninfo
|
||
local DEVICE_NAMES_RAW
|
||
DEVICE_NAMES_RAW=$(vulkan_device_names || true)
|
||
|
||
# Separate hardware GPUs from software renderers
|
||
local HARDWARE_GPUS=""
|
||
while IFS= read -r device_name; do
|
||
[ -z "$device_name" ] && continue
|
||
if is_software_renderer "$device_name"; then
|
||
continue
|
||
fi
|
||
if [ -n "$HARDWARE_GPUS" ]; then
|
||
HARDWARE_GPUS="${HARDWARE_GPUS}, ${device_name}"
|
||
else
|
||
HARDWARE_GPUS="$device_name"
|
||
fi
|
||
done <<< "$DEVICE_NAMES_RAW"
|
||
|
||
# If no hardware GPUs found, we can't determine compatibility
|
||
if [ -z "$HARDWARE_GPUS" ]; then
|
||
echo "unknown:No hardware GPU found (only software renderers)"
|
||
return 1
|
||
fi
|
||
|
||
# Get Vulkan features and check for VK_KHR_16bit_storage
|
||
# Modern GPUs (AMD, Intel Gen8+, NVIDIA) all support this extension
|
||
local FEATURES_OUTPUT
|
||
# Exits non-zero on drivers that still print usable output; the text is what matters.
|
||
FEATURES_OUTPUT=$(vulkaninfo --features 2>/dev/null) || true
|
||
|
||
if [ -n "$FEATURES_OUTPUT" ]; then
|
||
# Check for VK_KHR_16bit_storage extension or equivalent features
|
||
if echo "$FEATURES_OUTPUT" | grep -q "VK_KHR_16bit_storage"; then
|
||
echo "compatible:${HARDWARE_GPUS}"
|
||
return 0
|
||
fi
|
||
|
||
# Alternative: check for 16-bit storage features directly
|
||
if echo "$FEATURES_OUTPUT" | grep -Eq "storageBuffer16BitAccess[[:space:]]*=[[:space:]]*true|uniformAndStorageBuffer16BitAccess[[:space:]]*=[[:space:]]*true"; then
|
||
echo "compatible:${HARDWARE_GPUS}"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# If Vulkan features check didn't confirm support, check against known incompatible patterns
|
||
# This handles systems where vulkaninfo --features doesn't show the extension
|
||
local INCOMPATIBLE_GPUS=""
|
||
local HAS_COMPATIBLE_GPU=false
|
||
|
||
while IFS= read -r device_name; do
|
||
[ -z "$device_name" ] && continue
|
||
|
||
if is_software_renderer "$device_name"; then
|
||
continue
|
||
fi
|
||
|
||
# Check against known incompatible patterns
|
||
local is_incompatible=false
|
||
for pattern in "${INCOMPATIBLE_PATTERNS[@]}"; do
|
||
if echo "$device_name" | grep -iq "$pattern"; then
|
||
is_incompatible=true
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [ "$is_incompatible" = true ]; then
|
||
if [ -n "$INCOMPATIBLE_GPUS" ]; then
|
||
INCOMPATIBLE_GPUS="${INCOMPATIBLE_GPUS}, ${device_name}"
|
||
else
|
||
INCOMPATIBLE_GPUS="$device_name"
|
||
fi
|
||
else
|
||
# GPU doesn't match known incompatible patterns - assume compatible
|
||
HAS_COMPATIBLE_GPU=true
|
||
fi
|
||
done <<< "$DEVICE_NAMES_RAW"
|
||
|
||
if [ "$HAS_COMPATIBLE_GPU" = true ]; then
|
||
echo "compatible:${HARDWARE_GPUS}"
|
||
return 0
|
||
fi
|
||
|
||
if [ -n "$INCOMPATIBLE_GPUS" ]; then
|
||
echo "incompatible:${INCOMPATIBLE_GPUS}"
|
||
return 1
|
||
fi
|
||
|
||
echo "unknown:Could not classify Vulkan GPU compatibility"
|
||
return 1
|
||
}
|
||
|
||
# Detect available GPU backends for whisper.cpp and recommend the best option
|
||
detect_whispercpp_backends() {
|
||
detect_nvidia_gpu || true
|
||
detect_vulkan || true
|
||
|
||
# Check for Vulkan dev libraries
|
||
local HAS_VULKAN_DEV=false
|
||
if pkg-config --exists vulkan 2>/dev/null || [ -f /usr/include/vulkan/vulkan.h ]; then
|
||
HAS_VULKAN_DEV=true
|
||
fi
|
||
|
||
# Check for CUDA
|
||
local HAS_CUDA_DEV=false
|
||
if find_valid_cuda_toolkit_root quiet >/dev/null 2>&1; then
|
||
HAS_CUDA_DEV=true
|
||
fi
|
||
|
||
# Check Vulkan GPU compatibility (Gen7 and older Intel GPUs lack 16-bit storage support)
|
||
# Skip this check for NVIDIA GPUs since they use CUDA, not Vulkan
|
||
local VULKAN_COMPATIBLE="unknown"
|
||
local VULKAN_COMPAT_REASON=""
|
||
if [[ "$HAS_VULKAN" == "yes" && "$HAS_NVIDIA_GPU" != "yes" ]]; then
|
||
local COMPAT_RESULT
|
||
# Returns non-zero for "unknown"/"incompatible", which are answers, not errors.
|
||
COMPAT_RESULT=$(check_vulkan_gpu_compatibility) || true
|
||
VULKAN_COMPATIBLE=$(echo "$COMPAT_RESULT" | cut -d':' -f1)
|
||
VULKAN_COMPAT_REASON=$(echo "$COMPAT_RESULT" | cut -d':' -f2-)
|
||
elif [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then
|
||
# NVIDIA GPUs use CUDA, so Vulkan compatibility is irrelevant
|
||
VULKAN_COMPATIBLE="not_applicable"
|
||
VULKAN_COMPAT_REASON="NVIDIA GPU uses CUDA"
|
||
fi
|
||
|
||
# Determine recommendation (Priority: Vulkan > CUDA fallback > CPU).
|
||
# install_whispercpp_with_gpu_support tries Vulkan first on every GPU,
|
||
# including NVIDIA. CUDA is used only when Vulkan is unavailable or the
|
||
# Vulkan build fails, and only if a complete toolkit is already present.
|
||
# The installer never installs the CUDA toolkit.
|
||
# IMPORTANT: Vulkan *dev* libraries (libvulkan-dev, glslc) are installed
|
||
# later, so we recommend GPU when a compatible GPU is present even if
|
||
# those packages are not installed yet.
|
||
local RECOMMENDED_BACKEND="cpu"
|
||
local RECOMMENDED_REASON=""
|
||
local CAN_BUILD_GPU=false
|
||
|
||
if [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then
|
||
CAN_BUILD_GPU=true
|
||
if [[ "$HAS_VULKAN" == "yes" ]]; then
|
||
RECOMMENDED_BACKEND="vulkan"
|
||
RECOMMENDED_REASON="NVIDIA GPU detected (Vulkan)"
|
||
elif [[ "$HAS_CUDA_DEV" == "true" ]]; then
|
||
RECOMMENDED_BACKEND="cuda"
|
||
RECOMMENDED_REASON="NVIDIA GPU with CUDA toolkit installed"
|
||
else
|
||
RECOMMENDED_BACKEND="vulkan"
|
||
RECOMMENDED_REASON="NVIDIA GPU detected"
|
||
fi
|
||
# Vulkan-compatible GPU (AMD, Intel Gen8+) - second choice
|
||
elif [[ "$HAS_VULKAN" == "yes" && "$VULKAN_COMPATIBLE" == "compatible" ]]; then
|
||
RECOMMENDED_BACKEND="vulkan"
|
||
if [[ "$HAS_VULKAN_DEV" == "true" ]]; then
|
||
RECOMMENDED_REASON="Vulkan GPU detected with dev libraries"
|
||
else
|
||
RECOMMENDED_REASON="Vulkan GPU detected (dev libraries will be installed)"
|
||
fi
|
||
CAN_BUILD_GPU=true
|
||
# Vulkan GPU but compatibility unknown - allow GPU build as fallback
|
||
elif [[ "$HAS_VULKAN" == "yes" && "$VULKAN_COMPATIBLE" == "unknown" ]]; then
|
||
RECOMMENDED_BACKEND="vulkan"
|
||
RECOMMENDED_REASON="Possible Vulkan GPU (will verify during build)"
|
||
CAN_BUILD_GPU=true
|
||
# Incompatible Vulkan GPU (old Intel Gen7) - CPU only
|
||
elif [[ "$VULKAN_COMPATIBLE" == "incompatible" ]]; then
|
||
RECOMMENDED_BACKEND="cpu"
|
||
RECOMMENDED_REASON="Incompatible GPU ($VULKAN_COMPAT_REASON) - CPU mode recommended"
|
||
CAN_BUILD_GPU=false
|
||
else
|
||
RECOMMENDED_BACKEND="cpu"
|
||
RECOMMENDED_REASON="No compatible GPU detected"
|
||
CAN_BUILD_GPU=false
|
||
fi
|
||
|
||
echo "${RECOMMENDED_BACKEND}:${RECOMMENDED_REASON}:${CAN_BUILD_GPU}:${HAS_VULKAN}:${HAS_NVIDIA_GPU}:${HAS_VULKAN_DEV}:${HAS_CUDA_DEV}:${VULKAN_COMPATIBLE}:${VULKAN_COMPAT_REASON}"
|
||
}
|
||
|
||
# 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 [ -n "$VULKAN_SOFTWARE_DEVICE" ]; then
|
||
echo "whisper_cpp:✓:Software Vulkan only ($VULKAN_SOFTWARE_DEVICE) - whisper.cpp CPU mode"
|
||
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)
|
||
if command -v pkg-config >/dev/null 2>&1; then
|
||
local path=$(pkg-config --variable=typelibdir gobject-introspection-1.0 2>/dev/null)
|
||
if [ -n "$path" ] && [ -d "$path" ]; then
|
||
echo "$path"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# Fallback to common distribution-specific paths
|
||
# Order matters: more specific paths first
|
||
for path in \
|
||
/usr/lib/x86_64-linux-gnu/girepository-1.0 \
|
||
/usr/lib/aarch64-linux-gnu/girepository-1.0 \
|
||
/usr/lib/arm-linux-gnueabihf/girepository-1.0 \
|
||
/usr/lib/riscv64-linux-gnu/girepository-1.0 \
|
||
/usr/lib/powerpc64le-linux-gnu/girepository-1.0 \
|
||
/usr/lib/s390x-linux-gnu/girepository-1.0 \
|
||
/usr/lib64/girepository-1.0 \
|
||
/usr/lib/girepository-1.0 \
|
||
/usr/local/lib/girepository-1.0 \
|
||
/usr/local/lib64/girepository-1.0; do
|
||
if [ -d "$path" ]; then
|
||
echo "$path"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
# Ultimate fallback - will cause issues if wrong, but at least we try
|
||
echo "/usr/lib/girepository-1.0"
|
||
return 1
|
||
}
|
||
|
||
# Detect distribution
|
||
detect_distro
|
||
|
||
# Check compatibility. These tiers mirror docs/DISTRO_COMPATIBILITY.md, and the
|
||
# distro matrix builds on ubuntu, debian and fedora. Nothing here decides whether
|
||
# the install can proceed: what Vocalinux needs is an interpreter at the floor
|
||
# and that interpreter's distro PyGObject, checked by check_python_version()
|
||
# (fatal) and require_distro_gi() further down. This block only says how much
|
||
# help the package step is likely to be, so it must not turn away a distro the
|
||
# project documents as supported, and it must not key off a release label:
|
||
# derivatives carry their own numbering, so Linux Mint 22 and elementary OS 8
|
||
# report "22" and "8" while being built on Ubuntu 24.04 with Python 3.12.
|
||
case "$DISTRO_FAMILY" in
|
||
debian)
|
||
print_info "Detected Debian — fully supported. Continuing with Debian-specific configuration."
|
||
;;
|
||
ubuntu|fedora|arch|suse)
|
||
print_info "Detected $DISTRO_NAME ($DISTRO_FAMILY family) — supported."
|
||
;;
|
||
*)
|
||
print_warning "This installer has not been tested on $DISTRO_NAME; you may need to install dependencies manually."
|
||
print_warning "Vocalinux itself does not care which distribution it runs on, only that Python and PyGObject are new enough."
|
||
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
|
||
print_info "Non-interactive mode: continuing anyway..."
|
||
else
|
||
read_prompt -p "Do you want to continue anyway? (y/n) " -n 1 -r
|
||
echo
|
||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||
exit "$EXIT_USER_ABORT"
|
||
fi
|
||
fi
|
||
;;
|
||
esac
|
||
|
||
# Define XDG directories early: existing-install detection and the mode menu
|
||
# need CONFIG_DIR before system dependency installation asks anything.
|
||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
|
||
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
|
||
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
|
||
|
||
# An existing install is a usable venv plus a saved configuration: re-running
|
||
# the installer on one is an upgrade, not a first-time setup. A leftover
|
||
# config.json without a venv (from an aborted install) is not an install.
|
||
detect_existing_install() {
|
||
[ -f "$VENV_DIR/bin/activate" ] && [ -f "$CONFIG_DIR/config.json" ]
|
||
}
|
||
|
||
# Running via curl pipe but with a terminal: ask which mode to run. An existing
|
||
# install gets an upgrade entry as the default so re-running the installer is a
|
||
# non-event instead of a guided redo of the first-time setup.
|
||
choose_installation_mode() {
|
||
[[ "$INTERACTIVE_MODE" == "ask" ]] || return 0
|
||
|
||
echo ""
|
||
echo "Installation Mode:"
|
||
if [[ "${EXISTING_INSTALL:-no}" == "yes" ]]; then
|
||
echo " 1. Upgrade (keep current settings) - update Vocalinux, keep your setup"
|
||
echo " 2. Interactive - guided setup with recommendations"
|
||
echo " 3. Automatic - quick install with defaults (whisper.cpp)"
|
||
echo ""
|
||
read_prompt -p "Choose mode [1-3] (default: 1): " MODE_CHOICE
|
||
MODE_CHOICE=${MODE_CHOICE:-1}
|
||
|
||
case "$MODE_CHOICE" in
|
||
2)
|
||
INTERACTIVE_MODE="yes"
|
||
NON_INTERACTIVE="no"
|
||
;;
|
||
3)
|
||
AUTO_MODE="yes"
|
||
INTERACTIVE_MODE="no"
|
||
NON_INTERACTIVE="yes"
|
||
;;
|
||
*)
|
||
UPGRADE_MODE="yes"
|
||
AUTO_MODE="yes"
|
||
INTERACTIVE_MODE="no"
|
||
NON_INTERACTIVE="yes"
|
||
;;
|
||
esac
|
||
else
|
||
echo " 1. Interactive (recommended) - guided setup with recommendations"
|
||
echo " 2. Automatic - quick install with defaults (whisper.cpp)"
|
||
echo ""
|
||
read_prompt -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
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
# --upgrade on a machine with nothing installed cannot keep settings that do
|
||
# not exist: warn and proceed as a normal automatic install instead of failing.
|
||
resolve_upgrade_request() {
|
||
if [[ "${UPGRADE_MODE:-no}" == "yes" && "${EXISTING_INSTALL:-no}" != "yes" ]]; then
|
||
print_warning "--upgrade was given, but no existing install was found"
|
||
print_warning "(expected a venv in $VENV_DIR plus $CONFIG_DIR/config.json)."
|
||
print_info "Continuing with a normal automatic installation."
|
||
UPGRADE_MODE="no"
|
||
fi
|
||
}
|
||
|
||
# Print speech_recognition.engine from an existing config.json. Empty output
|
||
# when the file or the key is missing or unreadable.
|
||
read_configured_engine() {
|
||
local config_file="$1" python=""
|
||
[ -f "$config_file" ] || return 1
|
||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||
python="$VENV_DIR/bin/python"
|
||
elif command_exists python3; then
|
||
python="python3"
|
||
else
|
||
return 1
|
||
fi
|
||
"$python" - "$config_file" <<'PY' 2>/dev/null
|
||
import json
|
||
import sys
|
||
|
||
try:
|
||
with open(sys.argv[1]) as handle:
|
||
print(json.load(handle).get("speech_recognition", {}).get("engine", ""))
|
||
except Exception:
|
||
pass
|
||
PY
|
||
}
|
||
|
||
# Upgrade mode installs dependencies for the engine the existing configuration
|
||
# already selected instead of forcing the automatic default.
|
||
apply_upgrade_engine() {
|
||
[[ "${UPGRADE_MODE:-no}" == "yes" && -z "${SELECTED_ENGINE:-}" ]] || return 0
|
||
|
||
SELECTED_ENGINE=$(read_configured_engine "$CONFIG_DIR/config.json" || true)
|
||
if [ -n "$SELECTED_ENGINE" ]; then
|
||
print_info "Upgrade mode: keeping configured engine '$SELECTED_ENGINE'."
|
||
else
|
||
print_warning "No engine readable in $CONFIG_DIR/config.json; using the default (whisper.cpp)."
|
||
SELECTED_ENGINE="whisper_cpp"
|
||
fi
|
||
}
|
||
|
||
EXISTING_INSTALL="no"
|
||
if detect_existing_install; then
|
||
EXISTING_INSTALL="yes"
|
||
print_success "Existing configuration found at $CONFIG_DIR/config.json — it will be kept."
|
||
fi
|
||
|
||
resolve_upgrade_request
|
||
|
||
# Handle installation mode selection
|
||
choose_installation_mode
|
||
|
||
# Run interactive installation if selected
|
||
if [[ "$INTERACTIVE_MODE" == "yes" ]]; then
|
||
# Check if we have a TTY (required for interactive mode)
|
||
if [[ "$TERMINAL_PROMPTS" != "yes" ]]; then
|
||
print_error "Interactive mode requires a terminal (TTY)."
|
||
print_error "Download and run the installer directly from a terminal:"
|
||
print_error " curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh -o /tmp/vl.sh && bash /tmp/vl.sh"
|
||
exit 1
|
||
fi
|
||
|
||
# Run interactive installation
|
||
run_interactive_install
|
||
fi
|
||
|
||
apply_upgrade_engine
|
||
|
||
# Set default engine for auto/non-interactive mode
|
||
if [[ "$NON_INTERACTIVE" == "yes" ]] && [[ -z "$SELECTED_ENGINE" ]]; then
|
||
SELECTED_ENGINE="whisper_cpp"
|
||
print_info "Automatic mode: Installing with whisper.cpp (default engine)"
|
||
print_info "For other engines, use: --engine=whisper, --engine=vosk, --engine=parakeet, --engine=faster_whisper, or --engine=remote_api"
|
||
fi
|
||
|
||
|
||
# Install system dependencies
|
||
if [[ "$SKIP_SYSTEM_DEPS" == "yes" ]]; then
|
||
print_warning "Skipping system dependency installation (--skip-system-deps specified)."
|
||
print_warning "Make sure GTK, PyGObject, AppIndicator/Ayatana, PortAudio, and text input tools are installed."
|
||
else
|
||
install_system_dependencies
|
||
fi
|
||
|
||
# Install text input tools based on session type
|
||
install_text_input_tools
|
||
|
||
# Create necessary directories
|
||
print_info "Creating application directories..."
|
||
mkdir -p "$CONFIG_DIR"
|
||
mkdir -p "$DATA_DIR/models"
|
||
mkdir -p "$DESKTOP_DIR"
|
||
mkdir -p "$ICON_DIR"
|
||
|
||
# Interpreter the venv is built from. Resolved by select_python_interpreter();
|
||
# nothing below may fall back to a bare `python3` for venv creation.
|
||
PYTHON_CMD="python3"
|
||
|
||
# The distro interpreter its GTK/PyGObject packages are built for. Overridable
|
||
# (SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh) for systems that ship several.
|
||
SYSTEM_PYTHON="${SYSTEM_PYTHON:-/usr/bin/python3}"
|
||
|
||
python_version_of() {
|
||
"$1" -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>/dev/null
|
||
}
|
||
|
||
python_version_at_least() {
|
||
local version
|
||
version=$(python_version_of "$1") || return 1
|
||
[ -n "$version" ] || return 1
|
||
[[ $(printf '%s\n%s\n' "$version" "$2" | sort -V | head -n1) == "$2" ]]
|
||
}
|
||
|
||
python_has_gi() {
|
||
"$1" -c "import gi" >/dev/null 2>&1
|
||
}
|
||
|
||
# Pick the interpreter to build the venv from. Distro PyGObject is compiled for
|
||
# exactly one Python, so prefer a candidate that can already import gi:
|
||
# $SYSTEM_PYTHON is probed explicitly because PATH may expose a different
|
||
# interpreter (pyenv, uv, /usr/local) that the distro packages were never built
|
||
# for. Falls back to the newest-enough candidate when none of them has gi yet —
|
||
# require_distro_gi() reports that case with a proper message later on.
|
||
select_python_interpreter() {
|
||
local min_version="$1"
|
||
local candidates=() candidate seen="" first="" ok_version="" ok_system=""
|
||
|
||
if command_exists python3; then
|
||
candidates+=("$(command -v python3)")
|
||
fi
|
||
if [ -x "$SYSTEM_PYTHON" ]; then
|
||
candidates+=("$SYSTEM_PYTHON")
|
||
fi
|
||
|
||
for candidate in ${candidates[@]+"${candidates[@]}"}; do
|
||
case ":$seen:" in
|
||
*":$candidate:"*) continue ;;
|
||
esac
|
||
seen="${seen:+$seen:}$candidate"
|
||
|
||
[ -n "$first" ] || first="$candidate"
|
||
python_version_at_least "$candidate" "$min_version" || continue
|
||
[ -n "$ok_version" ] || ok_version="$candidate"
|
||
if [ "$candidate" = "$SYSTEM_PYTHON" ]; then
|
||
ok_system="$candidate"
|
||
fi
|
||
|
||
if python_has_gi "$candidate"; then
|
||
PYTHON_CMD="$candidate"
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
# No candidate has gi yet; it may be installed later in this run. Prefer the
|
||
# distro interpreter, because its PyGObject is the one that will show up.
|
||
PYTHON_CMD="${ok_system:-${ok_version:-$first}}"
|
||
[ -n "$PYTHON_CMD" ]
|
||
}
|
||
|
||
# Check Python version
|
||
check_python_version() {
|
||
# Keep in sync with requires-python in pyproject.toml.
|
||
local MIN_VERSION="3.11"
|
||
|
||
if ! select_python_interpreter "$MIN_VERSION"; then
|
||
print_error "Python 3 is not installed or not in PATH"
|
||
return 1
|
||
fi
|
||
|
||
local PY_VERSION
|
||
PY_VERSION=$(python_version_of "$PYTHON_CMD" || true)
|
||
print_info "Detected Python version: ${PY_VERSION:-unknown} ($PYTHON_CMD)"
|
||
|
||
if python_version_at_least "$PYTHON_CMD" "$MIN_VERSION"; then
|
||
return 0
|
||
fi
|
||
|
||
print_error "This application requires Python $MIN_VERSION or newer. Detected: ${PY_VERSION:-unknown}"
|
||
return 1
|
||
}
|
||
|
||
# An existing venv built by a different interpreter than the selected one is the
|
||
# classic cause of "distro PyGObject is not importable": PyGObject lives in the
|
||
# system Python's site-packages and --system-site-packages only exposes it to a
|
||
# venv of the *same* version. Such a venv otherwise survives every re-run,
|
||
# because the installer reuses whatever it finds.
|
||
# Where an interpreter's installation lives. For a venv this is the interpreter
|
||
# it was built from, which is what decides whether distro gi is visible.
|
||
python_base_prefix() {
|
||
"$1" -c "import sys; print(sys.base_prefix)" 2>/dev/null
|
||
}
|
||
|
||
venv_matches_selected_python() {
|
||
local venv_python="$VENV_DIR/bin/python"
|
||
local venv_base selected_base
|
||
|
||
[ -x "$venv_python" ] || return 1
|
||
|
||
# Compare installations, not the X.Y string: a distro 3.12 and a pyenv/uv
|
||
# 3.12 are not interchangeable, because distro PyGObject is importable only
|
||
# from the one it was built for.
|
||
venv_base=$(python_base_prefix "$venv_python") || return 1
|
||
selected_base=$(python_base_prefix "$PYTHON_CMD") || return 1
|
||
[ -n "$venv_base" ] && [ "$venv_base" = "$selected_base" ]
|
||
}
|
||
|
||
# Set up virtual environment with error handling
|
||
setup_virtual_environment() {
|
||
print_info "Setting up Python virtual environment in $VENV_DIR..."
|
||
|
||
# Discard a venv left behind by another interpreter before the reuse logic
|
||
# below can adopt it.
|
||
if [ -d "$VENV_DIR" ] && [ -f "$VENV_DIR/bin/activate" ] && ! venv_matches_selected_python; then
|
||
print_warning "Existing virtual environment in $VENV_DIR was built by a different Python than $PYTHON_CMD."
|
||
print_warning "Recreating it — distro PyGObject would stay invisible inside it otherwise."
|
||
rm -rf "$VENV_DIR"
|
||
fi
|
||
|
||
# Check if virtual environment already exists
|
||
if [ -d "$VENV_DIR" ] && [ -f "$VENV_DIR/bin/activate" ]; then
|
||
print_warning "Virtual environment already exists in $VENV_DIR"
|
||
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
|
||
# In non-interactive mode, reuse existing venv
|
||
print_info "Non-interactive mode: using existing virtual environment."
|
||
source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit "$EXIT_MISSING_DEPS"; }
|
||
return 0
|
||
else
|
||
read_prompt -p "Do you want to recreate it? (y/n) " -n 1 -r
|
||
echo
|
||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||
print_info "Removing existing virtual environment..."
|
||
rm -rf "$VENV_DIR"
|
||
else
|
||
print_info "Using existing virtual environment."
|
||
source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit "$EXIT_MISSING_DEPS"; }
|
||
return 0
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Create virtual environment
|
||
# Use --system-site-packages to access pre-compiled system packages like PyGObject
|
||
# This avoids build failures with Python 3.13+ where PyGObject may not build from source
|
||
"$PYTHON_CMD" -m venv --system-site-packages "$VENV_DIR" || {
|
||
print_warning "$PYTHON_CMD -m venv failed, trying $PYTHON_CMD -m virtualenv..."
|
||
"$PYTHON_CMD" -m virtualenv --system-site-packages "$VENV_DIR" || {
|
||
print_error "Failed to create virtual environment. Please check your Python installation."
|
||
exit "$EXIT_MISSING_DEPS"
|
||
}
|
||
}
|
||
|
||
# Activate virtual environment
|
||
source "$VENV_DIR/bin/activate" || { print_error "Failed to activate virtual environment"; exit "$EXIT_MISSING_DEPS"; }
|
||
|
||
print_info "Virtual environment activated successfully."
|
||
}
|
||
|
||
# Check Python version. Not advisory: distro PyGObject is built for the system
|
||
# interpreter, so a venv below the floor cannot import gi, and the install would
|
||
# fail later somewhere less obvious.
|
||
if ! check_python_version; then
|
||
print_error "Point SYSTEM_PYTHON at a newer interpreter if one is installed:"
|
||
print_error " SYSTEM_PYTHON=/usr/bin/python3.12 ./install.sh"
|
||
exit "$EXIT_MISSING_DEPS"
|
||
fi
|
||
|
||
# Set up virtual environment
|
||
setup_virtual_environment
|
||
|
||
# Also run for reused venvs: setup_virtual_environment returns early for those.
|
||
# Wheels only here so bootstrapping cannot itself resolve unpinned build deps.
|
||
install_pinned_build_tools() {
|
||
local reqs_file="$INSTALL_DIR/requirements/installer-build.txt"
|
||
if [ ! -s "$reqs_file" ]; then
|
||
print_error "Missing or empty pinned requirements: $reqs_file"
|
||
return 1
|
||
fi
|
||
print_info "Installing pinned pip and source-build tools..."
|
||
"$VENV_DIR/bin/python" -m pip install --require-hashes --no-deps \
|
||
--only-binary=:all: --ignore-installed \
|
||
-r "$reqs_file" --log "$VOCALINUX_TMP_DIR/bootstrap.log"
|
||
}
|
||
install_pinned_build_tools || {
|
||
print_error "Failed to install the pinned build tools. Check requirements/installer-build.txt."
|
||
exit "$EXIT_NETWORK"
|
||
}
|
||
|
||
# Create activation script for users
|
||
# Put it in ~/.local/bin when running remotely, or current dir when running locally
|
||
if [[ "$CLEANUP_ON_EXIT" == "yes" ]]; then
|
||
ACTIVATION_SCRIPT_DIR="$HOME/.local/bin"
|
||
mkdir -p "$ACTIVATION_SCRIPT_DIR"
|
||
else
|
||
ACTIVATION_SCRIPT_DIR="."
|
||
fi
|
||
ACTIVATION_SCRIPT="$ACTIVATION_SCRIPT_DIR/activate-vocalinux.sh"
|
||
|
||
cat > "$ACTIVATION_SCRIPT" << EOF
|
||
#!/bin/bash
|
||
# This script activates the Vocalinux virtual environment
|
||
export PYTHONNOUSERSITE=1
|
||
source "$VENV_DIR/bin/activate"
|
||
echo "Vocalinux virtual environment activated."
|
||
echo "To start the application, run: vocalinux"
|
||
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 and getattr(site, "ENABLE_USER_SITE", False):
|
||
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
|
||
}
|
||
|
||
is_pywhispercpp_gpu_capable() {
|
||
local LIB_DIRS
|
||
LIB_DIRS=$(get_pywhispercpp_library_path || true)
|
||
[ -n "$LIB_DIRS" ] || return 1
|
||
|
||
local IFS=:
|
||
for dir in $LIB_DIRS; do
|
||
if [ -f "$dir/libggml-vulkan.so" ] || [ -f "$dir/libggml-cuda.so" ]; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
is_pywhispercpp_backend_capable() {
|
||
local BACKEND
|
||
BACKEND=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
|
||
|
||
local LIB_DIRS
|
||
LIB_DIRS=$(get_pywhispercpp_library_path || true)
|
||
[ -n "$LIB_DIRS" ] || return 1
|
||
|
||
local EXPECTED_LIB=""
|
||
case "$BACKEND" in
|
||
cuda)
|
||
EXPECTED_LIB="libggml-cuda.so"
|
||
;;
|
||
vulkan)
|
||
EXPECTED_LIB="libggml-vulkan.so"
|
||
;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
local IFS=:
|
||
local LIB_DIR
|
||
for LIB_DIR in $LIB_DIRS; do
|
||
if compgen -G "$LIB_DIR/$EXPECTED_LIB*" >/dev/null; then
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
is_pywhispercpp_cuda_linkage_usable() {
|
||
if ! command -v readelf >/dev/null 2>&1; then
|
||
print_warning "readelf not found; skipping CUDA linkage verification." >&2
|
||
return 0
|
||
fi
|
||
|
||
local LIB_DIRS
|
||
LIB_DIRS=$(get_pywhispercpp_library_path || true)
|
||
[ -n "$LIB_DIRS" ] || return 1
|
||
|
||
local HAS_PATCHELF=false
|
||
if command -v patchelf >/dev/null 2>&1; then
|
||
HAS_PATCHELF=true
|
||
fi
|
||
|
||
local IFS=:
|
||
local LIB_DIR
|
||
for LIB_DIR in $LIB_DIRS; do
|
||
local CUDA_LIB
|
||
for CUDA_LIB in "$LIB_DIR"/libggml-cuda.so*; do
|
||
[ -f "$CUDA_LIB" ] || continue
|
||
local BUNDLED_LIBS
|
||
BUNDLED_LIBS=$(readelf -d "$CUDA_LIB" 2>/dev/null | sed -En 's/.*Shared library: \[(libcuda-[^]]+\.so).*/\1/p')
|
||
if [ -z "$BUNDLED_LIBS" ]; then
|
||
continue
|
||
fi
|
||
|
||
local BUNDLED_LIB
|
||
while IFS= read -r BUNDLED_LIB; do
|
||
[ -n "$BUNDLED_LIB" ] || continue
|
||
print_warning "CUDA backend links against bundled $BUNDLED_LIB instead of libcuda.so.1:"
|
||
print_warning " $CUDA_LIB"
|
||
|
||
if [[ "$HAS_PATCHELF" == "true" ]]; then
|
||
print_info "Attempting to relink with patchelf ($BUNDLED_LIB → libcuda.so.1)..."
|
||
if patchelf --replace-needed "$BUNDLED_LIB" libcuda.so.1 "$CUDA_LIB" 2>/dev/null; then
|
||
print_success "Successfully relinked $CUDA_LIB to libcuda.so.1"
|
||
else
|
||
print_warning "patchelf relink failed for $CUDA_LIB"
|
||
print_warning "Treating CUDA verification as failed so the installer does not report broken GPU support."
|
||
return 1
|
||
fi
|
||
else
|
||
print_warning "Install patchelf to attempt automatic relinking: sudo apt install patchelf"
|
||
print_warning "Treating CUDA verification as failed so the installer does not report broken GPU support."
|
||
return 1
|
||
fi
|
||
done <<< "$BUNDLED_LIBS"
|
||
done
|
||
done
|
||
|
||
return 0
|
||
}
|
||
|
||
verify_pywhispercpp_backend_install() {
|
||
local BACKEND="$1"
|
||
|
||
if ! is_pywhispercpp_installed; then
|
||
print_warning "pywhispercpp installed but import verification failed for $BACKEND backend."
|
||
return 1
|
||
fi
|
||
|
||
if ! is_pywhispercpp_backend_capable "$BACKEND"; then
|
||
print_warning "pywhispercpp installed but $BACKEND backend libraries were not found."
|
||
return 1
|
||
fi
|
||
|
||
if [[ "$BACKEND" == "CUDA" ]] && ! is_pywhispercpp_cuda_linkage_usable; then
|
||
return 1
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
print_pip_log_tail() {
|
||
local PIP_LOG_FILE="$1"
|
||
local LINE_COUNT="${2:-80}"
|
||
|
||
if [ -s "$PIP_LOG_FILE" ]; then
|
||
print_warning "Last $LINE_COUNT lines from pip build log ($PIP_LOG_FILE):"
|
||
tail -n "$LINE_COUNT" "$PIP_LOG_FILE" | sed 's/^/ /'
|
||
else
|
||
print_warning "Pip build log is empty or missing: $PIP_LOG_FILE"
|
||
fi
|
||
}
|
||
|
||
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)
|
||
|
||
# The PyPI wheel's libggml-cpu is compiled for AVX2+FMA and SIGILLs on
|
||
# pre-Haswell CPUs (#957), so the CPU path builds the pinned sdist like
|
||
# the GPU paths do; -march=native then matches this machine exactly.
|
||
CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" \
|
||
pip_reinstall_pywhispercpp "$PIP_LOG_FILE" --no-binary pywhispercpp
|
||
}
|
||
|
||
pip_reinstall_pywhispercpp() {
|
||
local pip_log="$1"
|
||
shift
|
||
local reqs_file="$VOCALINUX_TMP_DIR/pywhispercpp.txt"
|
||
"$VENV_DIR/bin/python" "$INSTALL_DIR/scripts/installer_requirements.py" \
|
||
"$INSTALL_DIR/requirements/runtime.txt" "$reqs_file" --package pywhispercpp || return 1
|
||
# --no-deps preserves the locked runtime and avoids reinstalling numpy
|
||
# while replacing only the backend. Build tools were bootstrapped above.
|
||
"$VENV_DIR/bin/python" -m pip install --require-hashes --no-deps --no-build-isolation \
|
||
--verbose --force-reinstall --no-cache-dir -r "$reqs_file" --log "$pip_log" "$@"
|
||
}
|
||
|
||
is_pywhispercpp_installed() {
|
||
[ -x "$VENV_DIR/bin/python" ] || return 1
|
||
with_pywhispercpp_library_path "$VENV_DIR/bin/python" -c "from pywhispercpp.model import Model" >/dev/null 2>&1
|
||
}
|
||
|
||
get_pywhispercpp_version() {
|
||
[ -x "$VENV_DIR/bin/python" ] || return 1
|
||
"$VENV_DIR/bin/python" - <<'PY' 2>/dev/null
|
||
from importlib import metadata
|
||
|
||
try:
|
||
print(metadata.version("pywhispercpp"))
|
||
except metadata.PackageNotFoundError:
|
||
raise SystemExit(1)
|
||
PY
|
||
}
|
||
|
||
should_rebuild_whispercpp() {
|
||
local INSTALLED_VERSION
|
||
INSTALLED_VERSION=$(get_pywhispercpp_version || true)
|
||
|
||
print_success "Found existing pywhispercpp installation${INSTALLED_VERSION:+ (version $INSTALLED_VERSION)}"
|
||
|
||
case "$REBUILD_WHISPERCPP" in
|
||
yes)
|
||
print_info "Rebuilding pywhispercpp because --rebuild-whispercpp was specified."
|
||
return 0
|
||
;;
|
||
no)
|
||
print_info "Reusing existing pywhispercpp installation."
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
if [[ "$NON_INTERACTIVE" == "yes" ]]; then
|
||
# An upgrade should pick up the pinned pywhispercpp: a version drift is
|
||
# the one case where reusing the existing build is not an update.
|
||
if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" != "$PYWHISPERCPP_VERSION" ]; then
|
||
print_info "pywhispercpp $INSTALLED_VERSION installed; this release pins $PYWHISPERCPP_VERSION. Rebuilding..."
|
||
return 0
|
||
fi
|
||
if [[ "$HAS_NVIDIA_GPU" == "yes" || "$HAS_VULKAN" == "yes" ]] && ! is_pywhispercpp_gpu_capable; then
|
||
print_info "Non-interactive mode: GPU detected but pywhispercpp lacks GPU support. Rebuilding..."
|
||
return 0
|
||
fi
|
||
print_info "Non-interactive mode: reusing existing pywhispercpp installation."
|
||
print_info "Use --rebuild-whispercpp to force a rebuild."
|
||
return 1
|
||
fi
|
||
|
||
if [[ "$HAS_NVIDIA_GPU" == "yes" || "$HAS_VULKAN" == "yes" ]] && ! is_pywhispercpp_gpu_capable; then
|
||
print_info "GPU detected but pywhispercpp lacks GPU support. Rebuilding..."
|
||
return 0
|
||
fi
|
||
|
||
read_prompt -p "Rebuild/reinstall pywhispercpp? This can take several minutes. (y/N) " -n 1 -r
|
||
echo
|
||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||
return 0
|
||
fi
|
||
|
||
print_info "Reusing existing pywhispercpp installation."
|
||
return 1
|
||
}
|
||
|
||
# Module each engine needs, and the pip name that provides it. Defined here so
|
||
# the whisper.cpp -> vosk fallback can rewrite an existing config.json; the
|
||
# writers above used to skip a file that already existed.
|
||
engine_import_module() {
|
||
case "$1" in
|
||
vosk) echo "vosk" ;;
|
||
whisper) echo "whisper" ;;
|
||
whisper_cpp) echo "pywhispercpp.model" ;;
|
||
parakeet) echo "sherpa_onnx" ;;
|
||
faster_whisper) echo "faster_whisper" ;;
|
||
*) echo "" ;;
|
||
esac
|
||
}
|
||
|
||
engine_pip_name() {
|
||
case "$1" in
|
||
vosk) echo "vosk" ;;
|
||
whisper) echo "openai-whisper" ;;
|
||
whisper_cpp) echo "pywhispercpp" ;;
|
||
parakeet) echo "sherpa-onnx" ;;
|
||
faster_whisper) echo "faster-whisper" ;;
|
||
*) echo "" ;;
|
||
esac
|
||
}
|
||
|
||
venv_can_import() {
|
||
[ -x "$VENV_DIR/bin/python" ] || return 1
|
||
"$VENV_DIR/bin/python" -c "import $1" >/dev/null 2>&1
|
||
}
|
||
|
||
engine_import_error() {
|
||
local ENGINE="$1"
|
||
local MODULE
|
||
MODULE=$(engine_import_module "$ENGINE")
|
||
[ -n "$MODULE" ] || return 1
|
||
|
||
local IMPORT_ERROR
|
||
if IMPORT_ERROR=$("$VENV_DIR/bin/python" -c "import $MODULE" 2>&1); then
|
||
return 0
|
||
fi
|
||
|
||
print_error "$ENGINE installed but \`import $MODULE\` failed:"
|
||
print_error "$(printf '%s\n' "$IMPORT_ERROR" | tail -n 1)"
|
||
return 1
|
||
}
|
||
|
||
# Point config.json at engine $2. Non-zero if it could not be rewritten.
|
||
set_configured_engine() {
|
||
"$VENV_DIR/bin/python" - "$1" "$2" <<'PY' 2>/dev/null
|
||
import json
|
||
import sys
|
||
|
||
path, engine = sys.argv[1], sys.argv[2]
|
||
with open(path) as handle:
|
||
config = json.load(handle)
|
||
config.setdefault("speech_recognition", {})["engine"] = engine
|
||
with open(path, "w") as handle:
|
||
json.dump(config, handle, indent=2)
|
||
handle.write("\n")
|
||
PY
|
||
}
|
||
|
||
# config.json is user data. Upgrade mode never modifies it: the write becomes
|
||
# a logged no-op so the configured engine survives the update byte-for-byte.
|
||
write_configured_engine() {
|
||
if [[ "${UPGRADE_MODE:-no}" == "yes" ]]; then
|
||
print_info "Upgrade mode: $1 is kept unchanged."
|
||
return 0
|
||
fi
|
||
set_configured_engine "$1" "$2"
|
||
}
|
||
|
||
install_whispercpp_with_gpu_support() {
|
||
local PIP_LOG_FILE="$1"
|
||
|
||
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 ""
|
||
|
||
# Detect GPU and install pywhispercpp with appropriate GPU support
|
||
detect_nvidia_gpu || true
|
||
detect_vulkan || true
|
||
|
||
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"
|
||
if should_rebuild_whispercpp; then
|
||
print_info "Existing pywhispercpp will be replaced."
|
||
else
|
||
SKIP_WHISPERCPP_INSTALL=true
|
||
fi
|
||
fi
|
||
|
||
# Check if user explicitly chose CPU backend in interactive mode
|
||
if [[ "$SKIP_WHISPERCPP_INSTALL" == "true" ]]; then
|
||
print_info "Skipping pywhispercpp reinstall; existing compiled bindings remain in place."
|
||
elif [[ "${WHISPERCPP_BACKEND}" == "cpu" ]]; then
|
||
print_info "ℹ Installing CPU-only version (as requested)..."
|
||
GPU_BACKEND="CPU"
|
||
else
|
||
# Try Vulkan first (works with all GPUs: NVIDIA, AMD, Intel)
|
||
if [[ "$HAS_VULKAN" == "yes" ]]; then
|
||
print_info "✓ Vulkan detected: $VULKAN_DEVICE"
|
||
print_info " Installing pywhispercpp with Vulkan support..."
|
||
GPU_BACKEND="Vulkan"
|
||
print_info "Installing pywhispercpp ($GPU_BACKEND backend)..."
|
||
if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" \
|
||
GGML_VULKAN=1 \
|
||
pip_reinstall_pywhispercpp "$PIP_LOG_FILE" --no-binary pywhispercpp 2>&1; then
|
||
if verify_pywhispercpp_backend_install "$GPU_BACKEND"; then
|
||
GPU_INSTALL_SUCCESS=true
|
||
else
|
||
print_pip_log_tail "$PIP_LOG_FILE"
|
||
fi
|
||
else
|
||
print_warning "Vulkan build failed; checking for NVIDIA GPU to try CUDA..."
|
||
print_pip_log_tail "$PIP_LOG_FILE"
|
||
fi
|
||
fi
|
||
|
||
# If Vulkan failed or not available, try CUDA for NVIDIA GPUs
|
||
if [[ "$GPU_INSTALL_SUCCESS" != "true" && "$HAS_NVIDIA_GPU" == "yes" ]]; then
|
||
print_info "✓ NVIDIA GPU detected: $GPU_NAME"
|
||
print_info " Installing pywhispercpp with CUDA support..."
|
||
GPU_BACKEND="CUDA"
|
||
|
||
local CUDA_TOOLKIT_ROOT=""
|
||
local CUDA_CMAKE_ARGS=""
|
||
if CUDA_TOOLKIT_ROOT=$(find_valid_cuda_toolkit_root); then
|
||
if CUDA_CMAKE_ARGS=$(get_cuda_cmake_args "$CUDA_TOOLKIT_ROOT"); then
|
||
print_info "Using CUDA toolkit: $CUDA_TOOLKIT_ROOT"
|
||
print_info "Installing pywhispercpp ($GPU_BACKEND backend)..."
|
||
if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS $CUDA_CMAKE_ARGS" \
|
||
GGML_CUDA=1 \
|
||
pip_reinstall_pywhispercpp "$PIP_LOG_FILE" --no-binary pywhispercpp 2>&1; then
|
||
if verify_pywhispercpp_backend_install "$GPU_BACKEND"; then
|
||
GPU_INSTALL_SUCCESS=true
|
||
else
|
||
print_pip_log_tail "$PIP_LOG_FILE"
|
||
fi
|
||
else
|
||
print_warning "CUDA build failed."
|
||
print_pip_log_tail "$PIP_LOG_FILE"
|
||
fi
|
||
else
|
||
print_warning "Skipping CUDA build because the detected toolkit cannot target this NVIDIA GPU."
|
||
fi
|
||
else
|
||
print_warning "No complete CUDA toolkit root found; skipping CUDA build."
|
||
print_info " Required CUDA files: bin/nvcc, include/cuda_runtime.h, and libcudart.so*"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Fall back to CPU version if GPU install failed or no GPU detected
|
||
if [[ "$SKIP_WHISPERCPP_INSTALL" != "true" && "$GPU_INSTALL_SUCCESS" != "true" ]]; then
|
||
if [[ "$GPU_BACKEND" != "CPU" ]]; then
|
||
print_warning "Failed to install pywhispercpp with $GPU_BACKEND support, falling back to CPU version..."
|
||
|
||
# Provide helpful error messages for common issues
|
||
if [[ "$GPU_BACKEND" == "Vulkan" ]]; then
|
||
print_info " To use Vulkan GPU acceleration, please install Vulkan development libraries:"
|
||
print_info " Ubuntu/Debian: sudo apt install libvulkan-dev vulkan-tools glslc || glslang-tools"
|
||
print_info " Fedora: sudo dnf install vulkan-loader-devel vulkan-tools glslc patchelf"
|
||
print_info " Arch: sudo pacman -S vulkan-headers vulkan-tools shaderc patchelf"
|
||
print_info " openSUSE: sudo zypper install vulkan-devel vulkan-tools shaderc patchelf"
|
||
elif [[ "$GPU_BACKEND" == "CUDA" ]]; then
|
||
print_info " To use CUDA GPU acceleration, please install CUDA toolkit:"
|
||
print_info " Visit: https://developer.nvidia.com/cuda-downloads"
|
||
fi
|
||
elif [[ "${WHISPERCPP_BACKEND}" != "cpu" ]]; then
|
||
print_info "ℹ No GPU detected - installing CPU-only version"
|
||
print_info " CPU mode is still very fast!"
|
||
fi
|
||
if [[ "$GPU_BACKEND" != "CPU" ]]; then
|
||
print_warning "Continuing with CPU-only pywhispercpp; GPU acceleration is not active."
|
||
fi
|
||
GPU_BACKEND="CPU"
|
||
print_info "Installing pywhispercpp ($GPU_BACKEND backend)..."
|
||
install_cpu_pywhispercpp "$PIP_LOG_FILE" || {
|
||
print_error "Failed to install pywhispercpp"
|
||
return 1
|
||
}
|
||
fi
|
||
|
||
if [[ "$SKIP_WHISPERCPP_INSTALL" == "true" ]]; then
|
||
print_success "pywhispercpp reused from existing installation"
|
||
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"
|
||
|
||
# vosk is an optional extra; make sure it is importable before
|
||
# falling back to it
|
||
if ! "$VENV_DIR/bin/python" -c "import vosk" 2>/dev/null; then
|
||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vosk || \
|
||
print_warning "Could not install vosk; install it manually or pick another engine in Settings."
|
||
fi
|
||
|
||
# Writing engine=vosk when the import still fails only moves the failure
|
||
# to startup, where it surfaces as ModuleNotFoundError: No module named
|
||
# 'vosk' and the app never comes up.
|
||
if ! "$VENV_DIR/bin/python" -c "import vosk" 2>/dev/null; then
|
||
print_warning "vosk is still not importable; leaving the engine configuration untouched."
|
||
SELECTED_ENGINE=""
|
||
return 0
|
||
fi
|
||
|
||
local FALLBACK_VOSK_CONFIG="$CONFIG_DIR/config.json"
|
||
if [ -f "$FALLBACK_VOSK_CONFIG" ]; then
|
||
if write_configured_engine "$FALLBACK_VOSK_CONFIG" "vosk"; then
|
||
[[ "${UPGRADE_MODE:-no}" != "yes" ]] &&
|
||
print_success "Switched $FALLBACK_VOSK_CONFIG to the vosk engine."
|
||
else
|
||
print_warning "Could not rewrite $FALLBACK_VOSK_CONFIG to vosk."
|
||
fi
|
||
elif [[ "${UPGRADE_MODE:-no}" != "yes" ]]; 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": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
},
|
||
"ui": {
|
||
"start_minimized": false,
|
||
"show_notifications": true,
|
||
"show_missing_tray_warning": true
|
||
},
|
||
"advanced": {
|
||
"debug_logging": false,
|
||
"wayland_mode": false
|
||
}
|
||
}
|
||
FALLBACK_VOSK_CONFIG
|
||
fi
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
# Exports use --no-emit-package pygobject: distro GI remains visible through
|
||
# --system-site-packages. Install the locked dependencies first, then the local
|
||
# project with --no-deps and --no-build-isolation so neither step re-resolves.
|
||
|
||
require_distro_gi() {
|
||
if ! "$VENV_DIR/bin/python" -c "import gi" 2>/dev/null; then
|
||
print_error "Distro PyGObject (python3-gi / python3-gobject) is not importable in the venv."
|
||
print_error "The venv was built from $PYTHON_CMD (Python $(python_version_of "$PYTHON_CMD" || echo unknown))."
|
||
print_error "Install it with your package manager. Pip cannot build PyGObject here."
|
||
exit "$EXIT_MISSING_DEPS"
|
||
fi
|
||
}
|
||
|
||
pip_install_reqs_file() {
|
||
local pip_log="$1"
|
||
local reqs_file="$2"
|
||
if [ ! -s "$reqs_file" ]; then
|
||
print_error "Missing or empty pinned requirements: $reqs_file"
|
||
return 1
|
||
fi
|
||
"$VENV_DIR/bin/python" -m pip install --require-hashes --no-deps --no-build-isolation \
|
||
-r "$reqs_file" --log "$pip_log"
|
||
}
|
||
|
||
pip_install_project_skip_pygobject() {
|
||
local pip_log="$1"
|
||
shift
|
||
require_distro_gi || return 1
|
||
pip_install_reqs_file "$pip_log" "$INSTALL_DIR/requirements/runtime.txt" || return 1
|
||
"$VENV_DIR/bin/python" -m pip install --no-deps --no-build-isolation --log "$pip_log" "$@"
|
||
}
|
||
|
||
pip_install_extras_skip_pygobject() {
|
||
local pip_log="$1"
|
||
shift
|
||
local extra
|
||
for extra in "$@"; do
|
||
# uv normalizes underscores to dashes in export filenames.
|
||
case "$extra" in
|
||
vad|vosk|parakeet|faster_whisper|whisper|dev) ;;
|
||
*) print_error "Unknown dependency extra: $extra"; return 1 ;;
|
||
esac
|
||
pip_install_reqs_file "$pip_log" "$INSTALL_DIR/requirements/${extra//_/-}.txt" || return 1
|
||
done
|
||
}
|
||
|
||
# Function to install Python package with error handling and verification
|
||
install_python_package() {
|
||
# Pip logs live in the install scratch dir so a failed run can keep them.
|
||
local PIP_LOG_DIR="$VOCALINUX_TMP_DIR/pip"
|
||
mkdir -p "$PIP_LOG_DIR"
|
||
local PIP_LOG_FILE="$PIP_LOG_DIR/pip_log.txt"
|
||
|
||
# Detect GI_TYPELIB_PATH early for cross-distro compatibility
|
||
# This ensures the path is available for both verification and wrapper scripts
|
||
# NOTE: global on purpose — install_desktop_entry (top level) reuses it.
|
||
GI_TYPELIB_DETECTED=$(detect_typelib_path || true)
|
||
print_info "Detected GI_TYPELIB_PATH: $GI_TYPELIB_DETECTED"
|
||
|
||
local WHISPERCPP_ALREADY_INSTALLED=false
|
||
if is_pywhispercpp_installed; then
|
||
WHISPERCPP_ALREADY_INSTALLED=true
|
||
fi
|
||
|
||
# Function to verify package installation
|
||
verify_package_installed() {
|
||
local PKG_NAME="vocalinux"
|
||
# Use venv python and set GI_TYPELIB_PATH for PyGObject
|
||
# Use the detected path for cross-distro compatibility
|
||
GI_TYPELIB_PATH="$GI_TYPELIB_DETECTED" "$VENV_DIR/bin/python" -c "import $PKG_NAME" 2>/dev/null
|
||
return $?
|
||
}
|
||
|
||
# Silero/ONNX Runtime gives much better speech/silence decisions, but
|
||
# onnxruntime wheels are not guaranteed for every Python/platform combo.
|
||
# Install it opportunistically so fresh installs and rerun-updates get the
|
||
# neural VAD when available without blocking the amplitude fallback path.
|
||
install_vad_support() {
|
||
local PIP_LOG_FILE="$1"
|
||
local EDITABLE_MODE="${2:-no}"
|
||
|
||
print_info "Installing neural VAD support (Silero / ONNX Runtime)..."
|
||
local VAD_INSTALL_SUCCESS=false
|
||
if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vad; then
|
||
VAD_INSTALL_SUCCESS=true
|
||
fi
|
||
|
||
if [[ "$VAD_INSTALL_SUCCESS" == "true" ]]; then
|
||
if "$VENV_DIR/bin/python" - <<'PY' 2>/dev/null
|
||
from vocalinux.speech_recognition.silero_vad import is_silero_available
|
||
raise SystemExit(0 if is_silero_available() else 1)
|
||
PY
|
||
then
|
||
print_success "Neural VAD support installed and verified successfully."
|
||
else
|
||
print_warning "Neural VAD dependencies installed, but Silero VAD could not be verified."
|
||
print_warning "Vocalinux will use amplitude-based VAD until this is resolved."
|
||
fi
|
||
else
|
||
print_warning "Failed to install neural VAD support."
|
||
print_warning "Vocalinux will still work using amplitude-based VAD."
|
||
print_warning "Check the pip log for details: $PIP_LOG_FILE"
|
||
fi
|
||
}
|
||
|
||
if [[ "$DEV_MODE" == "yes" ]]; then
|
||
print_info "Installing Vocalinux in development mode..."
|
||
|
||
# Install in development mode with logging
|
||
pip_install_project_skip_pygobject "$PIP_LOG_FILE" -e . || {
|
||
print_error "Failed to install Vocalinux in development mode."
|
||
print_error "Check the pip log for details: $PIP_LOG_FILE"
|
||
return 1
|
||
}
|
||
|
||
# Install test dependencies
|
||
print_info "Installing test dependencies..."
|
||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" dev || {
|
||
print_warning "Failed to install some test dependencies. Tests may not run correctly."
|
||
}
|
||
|
||
# Install all optional dependencies for development
|
||
print_info "Installing all optional dependencies for development..."
|
||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" whisper || {
|
||
print_warning "Failed to install some optional dependencies."
|
||
print_warning "Some features may not work correctly."
|
||
}
|
||
|
||
install_vad_support "$PIP_LOG_FILE" yes
|
||
|
||
if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" ]]; then
|
||
install_whispercpp_with_gpu_support "$PIP_LOG_FILE"
|
||
fi
|
||
else
|
||
print_info "Installing Vocalinux..."
|
||
|
||
# Install the package with logging (includes pywhispercpp by default)
|
||
pip_install_project_skip_pygobject "$PIP_LOG_FILE" . || {
|
||
print_error "Failed to install Vocalinux."
|
||
print_error "Check the pip log for details: $PIP_LOG_FILE"
|
||
return 1
|
||
}
|
||
|
||
install_vad_support "$PIP_LOG_FILE"
|
||
|
||
# Engine installation logic:
|
||
# - SELECTED_ENGINE is set by interactive mode or --engine flag
|
||
# - WHISPERCPP_BACKEND is set by interactive mode ("gpu" or "cpu")
|
||
# - Default is whisper_cpp for best performance
|
||
case "${SELECTED_ENGINE:-whisper_cpp}" in
|
||
whisper_cpp)
|
||
install_whispercpp_with_gpu_support "$PIP_LOG_FILE"
|
||
;;
|
||
|
||
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"
|
||
|
||
local WHISPER_INSTALL_SUCCESS=false
|
||
|
||
print_info "Installing pinned Whisper and CPU PyTorch..."
|
||
if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" whisper; then
|
||
if "$VENV_DIR/bin/python" -c "import whisper" 2>/dev/null; then
|
||
WHISPER_INSTALL_SUCCESS=true
|
||
print_success "Whisper installed and verified successfully"
|
||
else
|
||
print_error "Whisper package installed but import failed"
|
||
fi
|
||
else
|
||
print_error "Failed to install pinned Whisper dependencies"
|
||
fi
|
||
|
||
if [[ "$WHISPER_INSTALL_SUCCESS" == "true" ]]; then
|
||
# 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": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
},
|
||
"ui": {
|
||
"start_minimized": false,
|
||
"show_notifications": true,
|
||
"show_missing_tray_warning": true
|
||
},
|
||
"advanced": {
|
||
"debug_logging": false,
|
||
"wayland_mode": false
|
||
}
|
||
}
|
||
WHISPER_CONFIG
|
||
fi
|
||
else
|
||
print_warning "Failed to install Whisper (OpenAI)"
|
||
print_warning "Falling back to whisper.cpp (recommended engine)"
|
||
print_info ""
|
||
print_info "The Whisper (OpenAI) engine installation failed."
|
||
print_info "whisper.cpp will be installed instead, which is:"
|
||
print_info " - Faster and more accurate"
|
||
print_info " - Works with any GPU (NVIDIA, AMD, Intel)"
|
||
print_info " - Uses Vulkan for GPU acceleration"
|
||
print_info ""
|
||
|
||
# Fall back to whisper.cpp installation
|
||
install_cpu_pywhispercpp "$PIP_LOG_FILE" || {
|
||
print_error "Failed to install pywhispercpp fallback"
|
||
print_error "Please try installing manually: pip install pywhispercpp"
|
||
return 1
|
||
}
|
||
print_success "Installed whisper.cpp as fallback"
|
||
|
||
# Create config with whisper_cpp as default
|
||
local FALLBACK_CONFIG="$CONFIG_DIR/config.json"
|
||
if [ ! -f "$FALLBACK_CONFIG" ]; then
|
||
mkdir -p "$CONFIG_DIR"
|
||
cat > "$FALLBACK_CONFIG" << 'FALLBACK_CONFIG'
|
||
{
|
||
"speech_recognition": {
|
||
"engine": "whisper_cpp",
|
||
"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": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
},
|
||
"ui": {
|
||
"start_minimized": false,
|
||
"show_notifications": true,
|
||
"show_missing_tray_warning": true
|
||
},
|
||
"advanced": {
|
||
"debug_logging": false,
|
||
"wayland_mode": false
|
||
}
|
||
}
|
||
FALLBACK_CONFIG
|
||
fi
|
||
fi
|
||
;;
|
||
|
||
vosk)
|
||
print_info "Installing VOSK (lightweight option)..."
|
||
print_info "VOSK is fast and works well on older systems."
|
||
|
||
# vosk is an optional extra; install it alongside the base package
|
||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" vosk || {
|
||
print_error "Failed to install the vosk engine"
|
||
return 1
|
||
}
|
||
engine_import_error vosk || return 1
|
||
|
||
# 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
|
||
},
|
||
"audio": {
|
||
"device_index": null,
|
||
"device_name": null
|
||
},
|
||
"shortcuts": {
|
||
"toggle_recognition": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
},
|
||
"ui": {
|
||
"start_minimized": false,
|
||
"show_notifications": true,
|
||
"show_missing_tray_warning": true
|
||
},
|
||
"advanced": {
|
||
"debug_logging": false,
|
||
"wayland_mode": false
|
||
}
|
||
}
|
||
VOSK_CONFIG
|
||
fi
|
||
;;
|
||
|
||
parakeet)
|
||
print_info "Installing Parakeet (sherpa-onnx)..."
|
||
print_info "Parakeet runs NVIDIA NeMo ASR models on CPU."
|
||
|
||
# sherpa-onnx is an optional extra; install it alongside the base package
|
||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" parakeet || {
|
||
print_error "Failed to install the parakeet engine"
|
||
return 1
|
||
}
|
||
engine_import_error parakeet || return 1
|
||
|
||
# config_manager merges the remaining defaults, but a file with no
|
||
# shortcuts section reads as a pre-push-to-talk config and gets
|
||
# pinned back to ctrl+ctrl/toggle, so seed that section too.
|
||
mkdir -p "$CONFIG_DIR"
|
||
if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
||
cat > "$CONFIG_DIR/config.json" << 'PARAKEET_CONFIG'
|
||
{
|
||
"shortcuts": {
|
||
"toggle_recognition": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
}
|
||
}
|
||
PARAKEET_CONFIG
|
||
fi
|
||
write_configured_engine "$CONFIG_DIR/config.json" parakeet ||
|
||
print_warning "Could not point $CONFIG_DIR/config.json at the parakeet engine."
|
||
;;
|
||
|
||
remote_api)
|
||
print_info "Setting up Remote API engine..."
|
||
print_info ""
|
||
print_info "╔════════════════════════════════════════════════════════╗"
|
||
print_info "║ Setting up REMOTE API Engine ║"
|
||
print_info "╠════════════════════════════════════════════════════════╣"
|
||
print_info "║ • Offloads speech recognition to a remote server ║"
|
||
print_info "║ • Ideal for laptops without GPU ║"
|
||
print_info "║ • Supports whisper.cpp server & OpenAI APIs ║"
|
||
print_info "║ • Requires: a server running on your network ║"
|
||
print_info "╚════════════════════════════════════════════════════════╝"
|
||
print_info ""
|
||
|
||
# requests is already installed from the runtime export.
|
||
"$VENV_DIR/bin/python" -c "import requests" || {
|
||
print_error "The pinned requests library is not importable"
|
||
return 1
|
||
}
|
||
print_success "requests library installed"
|
||
|
||
# URL was collected upfront by run_interactive_install
|
||
# (or left blank in auto/non-interactive mode).
|
||
local REMOTE_API_URL="${REMOTE_API_URL:-}"
|
||
|
||
# Create configuration file
|
||
local REMOTE_CONFIG_FILE="$CONFIG_DIR/config.json"
|
||
if [ ! -f "$REMOTE_CONFIG_FILE" ]; then
|
||
mkdir -p "$CONFIG_DIR"
|
||
cat > "$REMOTE_CONFIG_FILE" << REMOTE_CONFIG
|
||
{
|
||
"speech_recognition": {
|
||
"engine": "remote_api",
|
||
"model_size": "small",
|
||
"vosk_model_size": "small",
|
||
"whisper_model_size": "tiny",
|
||
"whisper_cpp_model_size": "tiny",
|
||
"remote_api_url": "${REMOTE_API_URL}",
|
||
"remote_api_key": "",
|
||
"vad_sensitivity": 3,
|
||
"silence_timeout": 2.0
|
||
},
|
||
"audio": {
|
||
"device_index": null,
|
||
"device_name": null
|
||
},
|
||
"shortcuts": {
|
||
"toggle_recognition": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
},
|
||
"ui": {
|
||
"start_minimized": false,
|
||
"show_notifications": true,
|
||
"show_missing_tray_warning": true
|
||
},
|
||
"advanced": {
|
||
"debug_logging": false,
|
||
"wayland_mode": false
|
||
}
|
||
}
|
||
REMOTE_CONFIG
|
||
fi
|
||
|
||
if [ -n "$REMOTE_API_URL" ]; then
|
||
print_success "Remote API configured with server: $REMOTE_API_URL"
|
||
else
|
||
print_warning "No server URL configured. You can set it later in Settings."
|
||
fi
|
||
;;
|
||
|
||
faster_whisper)
|
||
print_info "Installing Faster-Whisper engine..."
|
||
print_info "This engine uses CTranslate2 for fast CPU inference."
|
||
|
||
if pip_install_extras_skip_pygobject "$PIP_LOG_FILE" faster_whisper &&
|
||
engine_import_error faster_whisper; then
|
||
mkdir -p "$CONFIG_DIR"
|
||
if [ ! -f "$CONFIG_DIR/config.json" ]; then
|
||
cat > "$CONFIG_DIR/config.json" << 'FASTER_WHISPER_CONFIG'
|
||
{
|
||
"shortcuts": {
|
||
"toggle_recognition": "right_alt+right_alt",
|
||
"mode": "push_to_talk"
|
||
}
|
||
}
|
||
FASTER_WHISPER_CONFIG
|
||
fi
|
||
write_configured_engine "$CONFIG_DIR/config.json" faster_whisper ||
|
||
print_warning "Could not point $CONFIG_DIR/config.json at the faster_whisper engine."
|
||
else
|
||
print_error "Failed to install the faster-whisper engine"
|
||
print_error "Falling back to whisper.cpp (recommended engine)"
|
||
install_cpu_pywhispercpp "$PIP_LOG_FILE" || {
|
||
print_error "Failed to install whisper.cpp fallback"
|
||
return 1
|
||
}
|
||
SELECTED_ENGINE="whisper_cpp"
|
||
fi
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# Verify installation
|
||
if verify_package_installed; then
|
||
print_success "Vocalinux package installed successfully!"
|
||
# Pip logs stay under VOCALINUX_TMP_DIR; the EXIT trap removes that
|
||
# directory on success and keeps it when a later step fails.
|
||
|
||
# GI_TYPELIB_PATH was already detected at the start of install_python_package
|
||
|
||
# Create wrapper scripts in ~/.local/bin for easy access
|
||
mkdir -p "$HOME/.local/bin"
|
||
|
||
# Shared sg check logic for wrapper scripts.
|
||
# Uses sg to activate the input group for Wayland keyboard shortcuts without logout.
|
||
# Single-quoted so install.sh does not expand; the wrapper expands $(whoami)/$EXEC_CMD
|
||
# at runtime. Do not write \$ — that leaves a literal $EXEC_CMD for exec.
|
||
local SG_CHECK='if grep -q "^input:.*\b$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q "\binput\b" && command -v sg &>/dev/null; then
|
||
exec sg input -c "$EXEC_CMD"
|
||
else
|
||
exec $EXEC_CMD
|
||
fi'
|
||
|
||
# Create vocalinux wrapper script
|
||
cat > "$HOME/.local/bin/vocalinux" << WRAPPER_EOF
|
||
#!/bin/bash
|
||
# Wrapper script for Vocalinux that sets required environment variables
|
||
# and applies the 'input' group for keyboard shortcuts on Wayland
|
||
export PYTHONNOUSERSITE=1
|
||
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
|
||
|
||
EXEC_CMD="$VENV_DIR/bin/vocalinux \$*"
|
||
$SG_CHECK
|
||
WRAPPER_EOF
|
||
chmod +x "$HOME/.local/bin/vocalinux"
|
||
print_info "Created wrapper: ~/.local/bin/vocalinux"
|
||
|
||
# Create vocalinux-gui wrapper script
|
||
cat > "$HOME/.local/bin/vocalinux-gui" << WRAPPER_EOF
|
||
#!/bin/bash
|
||
# Wrapper script for Vocalinux GUI that sets required environment variables
|
||
# and applies the 'input' group for keyboard shortcuts on Wayland
|
||
export PYTHONNOUSERSITE=1
|
||
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
|
||
|
||
EXEC_CMD="$VENV_DIR/bin/vocalinux-gui \$*"
|
||
$SG_CHECK
|
||
WRAPPER_EOF
|
||
chmod +x "$HOME/.local/bin/vocalinux-gui"
|
||
print_info "Created wrapper: ~/.local/bin/vocalinux-gui"
|
||
|
||
# Check if ~/.local/bin is in PATH
|
||
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||
print_warning "~/.local/bin is not in your PATH"
|
||
print_info "Add this line to your ~/.bashrc or ~/.zshrc:"
|
||
print_info ' export PATH="$HOME/.local/bin:$PATH"'
|
||
fi
|
||
|
||
return 0
|
||
else
|
||
print_error "Vocalinux package installation verification failed."
|
||
print_error "Check the pip log for details: $PIP_LOG_FILE"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Install Python package
|
||
if ! install_python_package; then
|
||
print_error "Failed to install Vocalinux package. Installation cannot continue."
|
||
exit "$EXIT_NETWORK"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Install desktop entry
|
||
install_desktop_entry || print_warning "Desktop entry installation failed"
|
||
|
||
# Install icons
|
||
install_icons || print_warning "Icon installation failed"
|
||
|
||
# Install resources to venv for runtime discovery
|
||
install_resources_to_venv || print_warning "Venv resource installation failed"
|
||
|
||
# Install models based on selected engine
|
||
# whisper.cpp is now the default engine
|
||
if [ "$SKIP_MODELS" = "no" ]; then
|
||
# Once, rather than once per engine.
|
||
if ! model_verification_available; then
|
||
print_warning "This release pins no model checksums, so the installer cannot verify"
|
||
print_warning "model downloads. The application will download and verify them on"
|
||
print_warning "first run instead."
|
||
print_warning "(The installer is newer than ${INSTALL_TAG:-the checked-out revision}.)"
|
||
fi
|
||
|
||
# Check which engines are installed and download appropriate models
|
||
|
||
# Install whisper.cpp model (default engine)
|
||
if is_pywhispercpp_installed; then
|
||
if model_verification_available; 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"
|
||
else
|
||
print_info "Leaving the whisper.cpp model to the first application run."
|
||
fi
|
||
fi
|
||
|
||
# Needs no manifest: the sha256 is a path segment of its own URL.
|
||
if "$VENV_DIR/bin/python" -c "import whisper" 2>/dev/null; then
|
||
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"
|
||
fi
|
||
else
|
||
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)
|
||
if [ "$SKIP_MODELS" = "no" ]; then
|
||
if model_verification_available; then
|
||
install_vosk_models || print_warning "VOSK model installation failed - models will be downloaded on first run"
|
||
else
|
||
print_info "Leaving the VOSK model to the first application run."
|
||
fi
|
||
else
|
||
print_info "Skipping VOSK model installation (--skip-models specified)"
|
||
print_info "Models will be downloaded automatically on first application run"
|
||
fi
|
||
|
||
# config.json survives reinstalls, so an engine picked by an earlier attempt
|
||
# can outlive the venv that supported it. Repair it here instead of letting the
|
||
# app die at startup with ModuleNotFoundError. Prefer SELECTED_ENGINE when that
|
||
# extra is importable (the whisper.cpp -> vosk fallback), then any working extra.
|
||
verify_configured_engine() {
|
||
local CONFIG_FILE="$CONFIG_DIR/config.json"
|
||
[ -f "$CONFIG_FILE" ] || return 0
|
||
|
||
local CONFIGURED_ENGINE
|
||
CONFIGURED_ENGINE=$("$VENV_DIR/bin/python" - "$CONFIG_FILE" <<'PY' 2>/dev/null || true
|
||
import json
|
||
import sys
|
||
|
||
try:
|
||
with open(sys.argv[1]) as handle:
|
||
print(json.load(handle).get("speech_recognition", {}).get("engine", ""))
|
||
except Exception:
|
||
pass
|
||
PY
|
||
)
|
||
|
||
local MODULE
|
||
MODULE=$(engine_import_module "$CONFIGURED_ENGINE")
|
||
# remote_api and anything unrecognised need no extra module.
|
||
[ -n "$MODULE" ] || return 0
|
||
venv_can_import "$MODULE" && return 0
|
||
|
||
print_warning "$CONFIG_FILE selects the $CONFIGURED_ENGINE engine, but it is not importable in $VENV_DIR."
|
||
|
||
if [[ "${UPGRADE_MODE:-no}" == "yes" ]]; then
|
||
# Upgrade mode never rewrites config.json: report instead of repairing.
|
||
print_error "Vocalinux cannot start with this configuration and the engine is not installed."
|
||
print_error "Install the engine: $VENV_DIR/bin/pip install $(engine_pip_name "$CONFIGURED_ENGINE")"
|
||
print_error "or edit $CONFIG_FILE and set speech_recognition.engine to an installed engine."
|
||
return 1
|
||
fi
|
||
|
||
# Leaving it means the app dies at startup with ModuleNotFoundError. Prefer
|
||
# SELECTED_ENGINE (set by the whisper.cpp -> vosk fallback), then any extra
|
||
# the venv can actually import.
|
||
local CANDIDATE MODULE_FOR_CANDIDATE TRIED=""
|
||
for CANDIDATE in ${SELECTED_ENGINE:+$SELECTED_ENGINE} whisper_cpp vosk whisper; do
|
||
[ "$CANDIDATE" != "$CONFIGURED_ENGINE" ] || continue
|
||
case " $TRIED " in
|
||
*" $CANDIDATE "*) continue ;;
|
||
esac
|
||
TRIED="$TRIED $CANDIDATE"
|
||
MODULE_FOR_CANDIDATE=$(engine_import_module "$CANDIDATE")
|
||
[ -n "$MODULE_FOR_CANDIDATE" ] || continue
|
||
venv_can_import "$MODULE_FOR_CANDIDATE" || continue
|
||
if set_configured_engine "$CONFIG_FILE" "$CANDIDATE"; then
|
||
SELECTED_ENGINE="$CANDIDATE"
|
||
print_success "Switched $CONFIG_FILE to the $CANDIDATE engine."
|
||
print_info "Reinstall $(engine_pip_name "$CONFIGURED_ENGINE") and switch back in Settings if you want it."
|
||
return 0
|
||
fi
|
||
done
|
||
|
||
print_error "Vocalinux cannot start with this configuration and no working engine is available."
|
||
print_error "Install the engine: $VENV_DIR/bin/pip install $(engine_pip_name "$CONFIGURED_ENGINE")"
|
||
print_error "or edit $CONFIG_FILE and set speech_recognition.engine to an installed engine."
|
||
return 1
|
||
}
|
||
|
||
if ! verify_configured_engine; then
|
||
exit "$EXIT_MISSING_DEPS"
|
||
fi
|
||
|
||
# Update icon cache
|
||
update_icon_cache
|
||
|
||
# Function to run tests with better error handling
|
||
run_tests() {
|
||
print_info "Running tests..."
|
||
|
||
# Check if pytest is installed in the virtual environment
|
||
if ! "$VENV_DIR/bin/python" -c "import pytest" &>/dev/null; then
|
||
print_info "Installing pytest and related packages..."
|
||
local PIP_LOG_FILE="$VOCALINUX_TMP_DIR/test-deps.log"
|
||
pip_install_extras_skip_pygobject "$PIP_LOG_FILE" dev || {
|
||
print_error "Failed to install pytest. Cannot run tests."
|
||
return 1
|
||
}
|
||
fi
|
||
|
||
# Create a directory for test results
|
||
local TEST_RESULTS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux/test_results"
|
||
mkdir -p "$TEST_RESULTS_DIR"
|
||
local TEST_RESULTS_FILE="$TEST_RESULTS_DIR/pytest_$(date +%Y%m%d_%H%M%S).xml"
|
||
|
||
print_info "Running tests with pytest..."
|
||
print_info "This may take a few minutes..."
|
||
|
||
# Run the tests with pytest and capture output
|
||
local TEST_OUTPUT_FILE=$(mktemp)
|
||
if pytest -v --junitxml="$TEST_RESULTS_FILE" | tee "$TEST_OUTPUT_FILE"; then
|
||
print_success "All tests passed!"
|
||
print_info "Test results saved to: $TEST_RESULTS_FILE"
|
||
rm -f "$TEST_OUTPUT_FILE"
|
||
return 0
|
||
else
|
||
local FAILED_COUNT=$(grep -c "FAILED" "$TEST_OUTPUT_FILE")
|
||
print_error "$FAILED_COUNT tests failed!"
|
||
print_info "Test results saved to: $TEST_RESULTS_FILE"
|
||
print_info "Check the test output for details."
|
||
rm -f "$TEST_OUTPUT_FILE"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Run tests if requested
|
||
if [[ "$RUN_TESTS" == "yes" ]]; then
|
||
if run_tests; then
|
||
print_success "Test suite completed successfully."
|
||
else
|
||
print_warning "Test suite completed with failures."
|
||
print_warning "You can still use the application, but some features might not work as expected."
|
||
fi
|
||
fi
|
||
|
||
# Function to verify the installation
|
||
verify_installation() {
|
||
print_info "Verifying installation..."
|
||
local ISSUES=0
|
||
|
||
# Check if virtual environment exists and is activated
|
||
if [ ! -d "$VENV_DIR" ] || [ ! -f "$VENV_DIR/bin/activate" ]; then
|
||
print_error "Virtual environment not found or incomplete."
|
||
ISSUES=$((ISSUES + 1))
|
||
fi
|
||
|
||
# Check if vocalinux command is available
|
||
if ! command -v vocalinux &>/dev/null && [ ! -f "$VENV_DIR/bin/vocalinux" ]; then
|
||
print_error "Vocalinux command not found."
|
||
ISSUES=$((ISSUES + 1))
|
||
fi
|
||
|
||
# Check if desktop entry is installed
|
||
if [ ! -f "$DESKTOP_DIR/vocalinux.desktop" ]; then
|
||
print_warning "Desktop entry not found. Application may not appear in application menu."
|
||
ISSUES=$((ISSUES + 1))
|
||
fi
|
||
|
||
# Check if icons are installed
|
||
local ICON_COUNT=0
|
||
for icon in vocalinux.svg vocalinux-microphone.svg vocalinux-microphone-off.svg vocalinux-microphone-process.svg; do
|
||
if [ -f "$ICON_DIR/$icon" ]; then
|
||
ICON_COUNT=$((ICON_COUNT + 1))
|
||
fi
|
||
done
|
||
|
||
if [ "$ICON_COUNT" -lt 4 ]; then
|
||
print_warning "Some icons are missing. Application may not display correctly."
|
||
ISSUES=$((ISSUES + 1))
|
||
fi
|
||
|
||
# Check if Python package is importable using venv python
|
||
if ! "$VENV_DIR/bin/python" -c "import vocalinux" &>/dev/null; then
|
||
print_error "Vocalinux Python package cannot be imported."
|
||
ISSUES=$((ISSUES + 1))
|
||
fi
|
||
|
||
# Smoke-test the selected speech engine's native library at install time so that
|
||
# "installed but does not run" failures (e.g. libwhisper.so.1 not found on Debian)
|
||
# are surfaced here with actionable guidance rather than silently at first launch.
|
||
local selected_engine="${SELECTED_ENGINE:-whisper_cpp}"
|
||
if [[ "$selected_engine" == "whisper_cpp" ]]; then
|
||
if ! is_pywhispercpp_installed; then
|
||
print_error "pywhispercpp (whisper.cpp engine) installed but cannot be imported at runtime."
|
||
print_error "This usually means libwhisper.so.1 is missing or not on the library path."
|
||
print_error ""
|
||
print_error "Diagnostic steps:"
|
||
print_error " 1. Check for unresolved symbols:"
|
||
print_error " ldd \$(find $VENV_DIR -name '*.so' -path '*/pywhispercpp*' 2>/dev/null | head -1) 2>/dev/null | grep 'not found'"
|
||
print_error " 2. Re-run the installer with: --rebuild-whispercpp"
|
||
print_error " 3. Or switch to VOSK (no native build needed): --engine=vosk"
|
||
ISSUES=$((ISSUES + 1))
|
||
else
|
||
print_success "pywhispercpp (whisper.cpp) import verified successfully."
|
||
fi
|
||
fi
|
||
|
||
if [[ "$selected_engine" == "faster_whisper" ]]; then
|
||
if ! "$VENV_DIR/bin/python" -c "import faster_whisper" 2>/dev/null; then
|
||
print_error "faster-whisper package installed but cannot be imported at runtime."
|
||
print_error ""
|
||
print_error "Diagnostic steps:"
|
||
print_error " 1. Check pip installation: $VENV_DIR/bin/pip show faster-whisper"
|
||
print_error " 2. Re-run the installer with: --engine=faster_whisper"
|
||
print_error " 3. Or switch to whisper.cpp: --engine=whisper_cpp"
|
||
ISSUES=$((ISSUES + 1))
|
||
else
|
||
print_success "faster-whisper import verified successfully."
|
||
fi
|
||
fi
|
||
|
||
# Return the number of issues found
|
||
return $ISSUES
|
||
}
|
||
|
||
# Function to print beautiful welcome message
|
||
print_welcome_message() {
|
||
local ISSUES=$1
|
||
|
||
# ASCII art header
|
||
cat << 'EOF'
|
||
|
||
▗▖ ▗▖ ▗▄▖ ▗▄▄▖ ▗▄▖ ▗▖ ▗▄▄▄▖▗▖ ▗▖▗▖ ▗▖▗▖ ▗▖
|
||
▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌ ▐▌▐▌ █ ▐▛▚▖▐▌▐▌ ▐▌ ▝▚▞▘
|
||
▐▌ ▐▌▐▌ ▐▌▐▌ ▐▛▀▜▌▐▌ █ ▐▌ ▝▜▌▐▌ ▐▌ ▐▌
|
||
▝▚▞▘ ▝▚▄▞▘▝▚▄▄▖▐▌ ▐▌▐▙▄▄▖▗▄█▄▖▐▌ ▐▌▝▚▄▞▘▗▞▘▝▚▖
|
||
|
||
✓ Installation Complete!
|
||
|
||
EOF
|
||
|
||
# Success or warning message
|
||
if [ "$ISSUES" -eq 0 ]; then
|
||
print_success "Vocalinux has been installed successfully!"
|
||
else
|
||
print_warning "Installation complete with $ISSUES minor issue(s)"
|
||
print_warning "The application should still work normally."
|
||
fi
|
||
|
||
# Get engine info for display
|
||
local ENGINE_INFO="${SELECTED_ENGINE:-whisper_cpp}"
|
||
local ENGINE_DISPLAY_NAME=""
|
||
local BACKEND_INFO=""
|
||
|
||
case "$ENGINE_INFO" in
|
||
whisper_cpp)
|
||
ENGINE_DISPLAY_NAME="Whisper.cpp"
|
||
if [[ "${WHISPERCPP_BACKEND}" == "gpu" ]]; then
|
||
BACKEND_INFO="GPU Accelerated"
|
||
else
|
||
BACKEND_INFO="CPU"
|
||
fi
|
||
;;
|
||
whisper)
|
||
ENGINE_DISPLAY_NAME="Whisper (OpenAI)"
|
||
BACKEND_INFO="PyTorch/CUDA"
|
||
;;
|
||
vosk)
|
||
ENGINE_DISPLAY_NAME="VOSK"
|
||
BACKEND_INFO="Lightweight"
|
||
;;
|
||
parakeet)
|
||
ENGINE_DISPLAY_NAME="Parakeet"
|
||
BACKEND_INFO="CPU"
|
||
;;
|
||
faster_whisper)
|
||
ENGINE_DISPLAY_NAME="Faster-Whisper"
|
||
BACKEND_INFO="PyTorch/CTranslate2"
|
||
;;
|
||
remote_api)
|
||
ENGINE_DISPLAY_NAME="Remote API"
|
||
BACKEND_INFO="Network (offloaded to remote server)"
|
||
;;
|
||
esac
|
||
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " 📦 What Was Installed"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo " Application: Vocalinux (voice dictation for Linux)"
|
||
echo " Engine: $ENGINE_DISPLAY_NAME"
|
||
if [[ -n "$BACKEND_INFO" ]]; then
|
||
echo " Backend: $BACKEND_INFO"
|
||
fi
|
||
echo " Location: ${INSTALL_DIR:-\$HOME/.local/share/vocalinux}"
|
||
echo " Virtual Env: $VENV_DIR"
|
||
echo " Config: $CONFIG_DIR"
|
||
echo ""
|
||
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " 🚀 Getting Started"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "1. Launch Vocalinux"
|
||
echo " • From app menu: Look for 'Vocalinux'"
|
||
echo " • From terminal: Run 'vocalinux' command"
|
||
echo ""
|
||
echo "2. Find the icon in your system tray (top bar)"
|
||
echo " • Click for settings and status"
|
||
echo " • Right-click for menu options"
|
||
echo ""
|
||
echo "3. Start dictating!"
|
||
echo -e " \e[1mHold Right Alt\e[0m while you speak, then release to transcribe"
|
||
echo -e " (push-to-talk default; double-tap \e[1mRight Alt\e[0m in toggle mode)"
|
||
echo ""
|
||
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " 🎤 Testing Your Setup"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "1. Open any text editor (gedit, VS Code, LibreOffice, etc.)"
|
||
echo "2. Hold Right Alt, say: 'Hello world period', then release"
|
||
echo "3. You should see: 'Hello world.'"
|
||
echo ""
|
||
echo "💡 Voice commands: 'period' 'comma' 'new line' 'delete that'"
|
||
echo ""
|
||
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " 🔧 Managing Vocalinux"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Commands:"
|
||
echo " vocalinux Start the application"
|
||
echo " vocalinux --debug Start with debug logging"
|
||
echo " vocalinux-gui Open settings GUI"
|
||
echo ""
|
||
echo "To activate the virtual environment:"
|
||
echo " source ${ACTIVATION_SCRIPT:-activate-vocalinux.sh}"
|
||
echo ""
|
||
echo "To uninstall:"
|
||
echo " ./uninstall.sh"
|
||
echo ""
|
||
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " 📚 Need Help?"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "• Issues & Bugs: https://github.com/VocaHQ/vocalinux/issues"
|
||
echo "• Documentation: https://github.com/VocaHQ/vocalinux"
|
||
echo "• Star on GitHub: ⭐ https://github.com/VocaHQ/vocalinux"
|
||
echo ""
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo -e " \e[1m\e[32m✨ Happy Dictating! ✨\e[0m"
|
||
echo ""
|
||
|
||
# Installation details (optional, for debugging)
|
||
if [[ "${VERBOSE:-no}" == "yes" ]]; then
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo " 🔍 Installation Details (Debug Mode)"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo ""
|
||
echo "Virtual environment: $VENV_DIR"
|
||
echo "Desktop entry: $DESKTOP_DIR/vocalinux.desktop"
|
||
echo "Configuration: $CONFIG_DIR"
|
||
echo "Data directory: $DATA_DIR"
|
||
echo "Wrapper script: $HOME/.local/bin/vocalinux"
|
||
echo ""
|
||
fi
|
||
}
|
||
|
||
# Verify the installation
|
||
# (guarded: verify_installation returns the number of issues found, which
|
||
# would otherwise abort the script under set -e before the summary prints)
|
||
INSTALL_ISSUES=0
|
||
verify_installation || INSTALL_ISSUES=$?
|
||
|
||
# Print welcome message
|
||
print_welcome_message $INSTALL_ISSUES
|
||
|
||
print_success "Installation process completed!"
|