Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/bin/bash

# Speech-to-Text Installation Script
# Works on bare Ubuntu/Debian installations

set -e  # Exit on error

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Configuration
INSTALL_DIR="$(pwd)"
SERVICE_NAME="stt-server"
PYTHON_BIN=""

# Function to print colored output
print_status() {
    echo -e "${BLUE}[INFO]${NC} $1"
}

print_success() {
    echo -e "${GREEN}[OK]${NC} $1"
}

print_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

print_warning() {
    echo -e "${YELLOW}[WARNING]${NC} $1"
}

# Function to check if running as root
check_not_root() {
    if [ "$EUID" -eq 0 ]; then
        print_error "This script should not be run as root for security reasons."
        print_error "Please run as a regular user with sudo access."
        exit 1
    fi
}

# Function to detect OS
detect_os() {
    local uname_s
    uname_s=$(uname -s)

    case $uname_s in
        Darwin)
            OS=macos
            ARCH=$(uname -m)
            # PyTorch published its last macOS x86_64 wheels with 2.2.2, so the
            # pinned torch in requirements.txt cannot resolve on an Intel Mac.
            # Stop here rather than after installing Python, ffmpeg and a venv.
            if [ "$ARCH" != "arm64" ]; then
                print_error "This Mac has an Intel processor. STT needs PyTorch, which no longer publishes Intel-Mac builds, so its dependencies cannot be installed here."
                print_error "Run STT on an Apple Silicon Mac (M1 or newer), or on a Windows or Linux PC."
                exit 1
            fi
            # scipy and scikit-learn (pulled in by librosa) have never
            # published an arm64 wheel tagged below macosx_12_0 for Python
            # 3.11, so on Big Sur there is nothing to install and nothing to
            # fall back to — uv builds from source and fails hours later.
            MACOS_MAJOR=$(sw_vers -productVersion 2>/dev/null | cut -d. -f1)
            if [ -n "$MACOS_MAJOR" ] && [ "$MACOS_MAJOR" -lt 12 ] 2>/dev/null; then
                print_error "This Mac runs macOS $MACOS_MAJOR, and STT needs macOS Monterey (12) or later — the audio libraries it depends on stopped publishing builds for older versions."
                print_error "The update is free for every Apple Silicon Mac: open System Preferences -> Software Update, install it, then run this installer again."
                exit 1
            fi
            print_success "Detected macOS ($ARCH)"
            ;;
        Linux)
            if [ -f /etc/os-release ]; then
                . /etc/os-release
                OS=$ID
                VERSION_ID=$VERSION_ID
            else
                print_warning "Cannot read /etc/os-release. Assuming generic Linux."
                OS=linux
            fi
            case $OS in
                ubuntu|debian)
                    print_success "Detected $OS $VERSION_ID"
                    ;;
                *)
                    print_warning "Untested Linux distro: $OS. Script designed for Debian/Ubuntu."
                    read -p "Continue anyway? (y/N): " -n 1 -r
                    echo
                    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
                        exit 1
                    fi
                    ;;
            esac
            ;;
        *)
            print_error "Unsupported OS: $uname_s. This script supports Linux (Debian/Ubuntu) and macOS."
            exit 1
            ;;
    esac
}

# Function to check and install system packages
install_system_deps() {
    print_status "Checking system packages..."

    if [ "$OS" = "macos" ]; then
        # macOS: use Homebrew
        if ! command -v brew &> /dev/null; then
            print_error "Homebrew not found. Install it from https://brew.sh/ then re-run this script."
            exit 1
        fi
        print_status "Installing system packages via Homebrew..."
        brew install python3 ffmpeg
    else
        # Linux: use apt-get
        print_status "Updating package list..."
        sudo apt-get update

        PACKAGES=(
            "python3"
            "python3-dev"
            "python3-tk"
            "build-essential"
            "git"
            "curl"
            "wget"
            "ffmpeg"
            "libasound2-dev"
            "alsa-utils"
            "psmisc"
        )

        print_status "Installing system packages: ${PACKAGES[*]}"
        sudo apt-get install -y "${PACKAGES[@]}"
    fi

    # Verify installations
    if ! command -v python3 &> /dev/null; then
        print_error "Python3 installation failed"
        exit 1
    fi

    if ! command -v ffmpeg &> /dev/null; then
        print_error "FFmpeg installation failed"
        exit 1
    fi

    print_success "System packages installed successfully"
}

# Function to add user to audio group (Linux only)
setup_audio_permissions() {
    if [ "$OS" = "macos" ]; then
        print_status "Skipping audio group setup (not needed on macOS)"
        return
    fi

    print_status "Setting up audio permissions..."

    # Check if user is already in audio group
    if groups "$USER" | grep -q '\baudio\b'; then
        print_success "User already in audio group"
    else
        print_status "Adding user to audio group..."
        sudo usermod -a -G audio "$USER"
        print_warning "User added to audio group. You may need to log out and back in for changes to take effect."
    fi

    # Check if pulseaudio or pipewire is running
    if pgrep -x "pulseaudio" > /dev/null || pgrep -x "pipewire" > /dev/null; then
        print_success "Audio server detected"
    else
        print_warning "No audio server detected. For desktop audio, consider installing pulseaudio or pipewire."
    fi
}

# Function to install uv (fast Python package installer)
install_uv() {
    print_status "Installing uv package manager..."

    if command -v uv &> /dev/null; then
        print_success "uv already installed: $(uv --version)"
        return
    fi

    curl -LsSf https://astral.sh/uv/install.sh | sh

    # Add uv to PATH for current session
    export PATH="$HOME/.local/bin:$PATH"

    if command -v uv &> /dev/null; then
        print_success "uv installed: $(uv --version)"
    else
        print_error "uv installation failed"
        exit 1
    fi
}

# Function to create Python virtual environment
create_venv() {
    print_status "Creating Python virtual environment..."
    # --clear replaces an existing .venv without the interactive y/n prompt
    # (unattended/headless installs would otherwise hang on a keypress)
    uv venv --clear "$INSTALL_DIR/.venv"
    export VIRTUAL_ENV="$INSTALL_DIR/.venv"
    PYTHON_BIN="$INSTALL_DIR/.venv/bin/python3"
    print_success "Virtual environment created at $INSTALL_DIR/.venv"
}

# Function to detect GPU and configure PyTorch
detect_gpu() {
    print_status "Detecting GPU..."

    if [ "$OS" = "macos" ]; then
        if [ "$(uname -m)" = "arm64" ]; then
            print_success "Apple Silicon detected — MPS (Metal) GPU acceleration available"
            return 0
        fi
        # Unreachable via main() — detect_os exits on an Intel Mac. Kept so this
        # stays a check rather than a claim if the call order ever changes.
        print_error "This Mac has an Intel processor — unsupported (see detect_os)"
        return 1
    else
        if command -v nvidia-smi &> /dev/null; then
            print_success "NVIDIA GPU detected:"
            nvidia-smi --query-gpu=name,memory.total --format=csv,noheader

            # Check CUDA version
            CUDA_VERSION=$(nvidia-smi | grep "CUDA Version" | awk '{print $9}')
            if [ -n "$CUDA_VERSION" ]; then
                print_status "CUDA Version: $CUDA_VERSION"
            fi

            return 0
        else
            print_warning "No NVIDIA GPU detected or drivers not installed"
            print_warning "Will install CPU-only PyTorch"
            return 1
        fi
    fi
}

# Pick the torch wheel index this GPU can actually run.
#
# cu128 dropped the pre-Volta architectures, so on a Pascal card (GTX 10-series,
# sm_61) a cu128 install reports cuda.is_available() True and then fails every kernel
# launch with "no kernel image is available for execution on the device". The index is
# therefore chosen by compute capability, not by the presence of nvidia-smi.
#
# Mirrors stt/cuda_index.py, which carries the tests; keep the two in step. Prints
# nothing when the capability cannot be read, so the caller installs the default wheel
# rather than guessing: a CPU build is slow and obvious, a CUDA build with no kernels
# for the card looks perfect until the first service.
torch_index_url() {
    local caps lowest
    caps=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null) || return 0
    # Lowest capability wins: the wheels have to run on every card in the box.
    lowest=$(printf '%s\n' "$caps" | grep -E '^[0-9]+\.[0-9]+$' | sort -g | head -1)
    [ -z "$lowest" ] && return 0
    if [ "$(printf '%s\n7.0\n' "$lowest" | sort -g | head -1)" = "7.0" ]; then
        echo "https://download.pytorch.org/whl/cu128"
    else
        echo "https://download.pytorch.org/whl/cu126"
    fi
}

# Compiled packages that must arrive as wheels or not at all. uv otherwise falls
# back to building from source when no wheel matches the platform, which needs a
# compiler this machine may not have; refusing the fallback makes uv pick a
# release that does ship a wheel here. Mirrors stt/wheel_policy.py, which is the
# source of truth and carries the tests — keep the two in step.
ONLY_BINARY="llvmlite,numba,numpy,scikit-learn,scipy,soxr"

# Function to install Python packages
install_python_deps() {
    print_status "Installing Python dependencies from requirements.txt..."

    if [ "$OS" = "macos" ]; then
        # macOS: standard PyPI PyTorch includes MPS support; no CUDA index needed
        detect_gpu  # Just for informational output
        print_status "Installing packages (MPS/CPU)..."
        uv pip install -r requirements.txt --only-binary "$ONLY_BINARY"
    else
        # Linux: install CUDA PyTorch if NVIDIA GPU present, otherwise CPU
        if detect_gpu; then
            index_url=$(torch_index_url)
            if [ -n "$index_url" ]; then
                print_status "Installing GPU-enabled packages ($index_url)..."
                uv pip install -r requirements.txt --only-binary "$ONLY_BINARY" --extra-index-url "$index_url"
            else
                print_status "Installing default packages..."
                uv pip install -r requirements.txt --only-binary "$ONLY_BINARY"
            fi
        else
            print_status "Installing CPU-only packages..."
            uv pip install -r requirements.txt --only-binary "$ONLY_BINARY"
        fi
    fi

    print_success "Python dependencies installed"
}

# Function to verify Python installation
verify_python_deps() {
    print_status "Verifying Python installation..."

    "$PYTHON_BIN" -c "
import sys
try:
    import torch
    print(f'PyTorch {torch.__version__} installed')
    if torch.cuda.is_available():
        print(f'CUDA available: {torch.cuda.get_device_name(0)}')
    elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
        print('MPS (Apple Silicon GPU) available')
    else:
        print('GPU not available (CPU mode)')

    import speech_recognition
    print('SpeechRecognition installed')

    import numpy
    print('NumPy installed')

    import flask
    print('Flask installed')

    print('\nAll critical packages verified!')
except ImportError as e:
    print(f'Import error: {e}')
    sys.exit(1)
"

    print_success "Python verification passed"
}


# Function to list audio devices
list_audio_devices() {
    print_status "Listing available audio devices..."

    "$PYTHON_BIN" -c "
from audio_capture import list_audio_devices
devices = list_audio_devices()
print('\\nAvailable audio devices:')
print('-' * 60)
for i, device in enumerate(devices):
    print(f'{i}: {device[\"name\"]}')
    print(f'   Display: {device.get(\"display_name\", device[\"name\"])}')
    if device.get('is_default'):
        print('   [DEFAULT]')
    print()
"
}

# Function to test audio capture
test_audio_capture() {
    print_status "Would you like to test audio capture?"
    read -p "Test audio capture? (y/N): " -n 1 -r
    echo

    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        return
    fi

    print_status "Testing audio capture for 3 seconds..."
    "$PYTHON_BIN" << 'PYEOF'
import sys
sys.path.insert(0, '.')
from audio_capture import FFmpegAudioCapture
import time

try:
    capture = FFmpegAudioCapture(sample_rate=16000, chunk_duration=1.0, device_name=None)
    capture.ts_enabled = False  # Don't create backup for test
    
    print("Starting capture...")
    capture.start()
    time.sleep(3)
    capture.stop()
    print("\nAudio capture test completed successfully!")
except Exception as e:
    import platform
    print(f"\nAudio capture test failed: {e}")
    print("\nTroubleshooting:")
    print("1. Check if microphone is connected and permitted")
    if platform.system() == "Darwin":
        print("2. macOS: Grant microphone access in System Settings > Privacy & Security > Microphone")
        print("3. List devices: ffmpeg -f avfoundation -list_devices true -i \"\"")
        print("4. Try device ':0' or ':1' as default_microphone in ~/.stt/config/config.json")
    else:
        print("2. Check if user is in 'audio' group (log out and back in if added)")
        print("3. Try: arecord -l  (to list recording devices)")
        print("4. Check ALSA: cat /proc/asound/cards")
PYEOF
}

# Function to create systemd service (Linux) or LaunchAgent (macOS)
setup_systemd_service() {
    if [ "$OS" = "macos" ]; then
        setup_launchd_agent
    else
        setup_systemd_service_linux
    fi
}

setup_launchd_agent() {
    local PLIST_LABEL="com.stt.server"
    local PLIST_PATH="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
    local PYTHON_BIN="$INSTALL_DIR/.venv/bin/python3"

    print_status "Would you like to set up a LaunchAgent for auto-start on login?"
    read -p "Setup LaunchAgent? (y/N): " -n 1 -r
    echo

    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        return
    fi

    mkdir -p "$HOME/Library/LaunchAgents"

    cat > "$PLIST_PATH" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>$PLIST_LABEL</string>
    <key>ProgramArguments</key>
    <array>
        <string>$PYTHON_BIN</string>
        <string>$INSTALL_DIR/speech_to_text.py</string>
    </array>
    <key>WorkingDirectory</key>
    <string>$INSTALL_DIR</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PYTHONUNBUFFERED</key>
        <string>1</string>
    </dict>
    <key>StandardOutPath</key>
    <string>$INSTALL_DIR/server.log</string>
    <key>StandardErrorPath</key>
    <string>$INSTALL_DIR/server.log</string>
</dict>
</plist>
PLISTEOF

    launchctl load "$PLIST_PATH"

    print_success "LaunchAgent created and loaded"
    print_status "Service commands:"
    print_status "  launchctl start $PLIST_LABEL   - Start service"
    print_status "  launchctl stop $PLIST_LABEL    - Stop service"
    print_status "  launchctl unload $PLIST_PATH   - Remove service"
    print_status "  tail -f $INSTALL_DIR/server.log  - View logs"
}

# Allow the installing user (and an unprivileged service user) to restart the
# STT units without a password. The in-app auto-updater tries plain systemctl,
# then `sudo -n systemctl restart`; without this rule a unit running as a
# regular user can only fall back to an in-place execv restart.
install_restart_sudoers() {
    local SYSTEMCTL_BIN SUDOERS_FILE
    SYSTEMCTL_BIN=$(command -v systemctl) || return 0
    SUDOERS_FILE="/etc/sudoers.d/stt-restart"
    printf '%s ALL=(root) NOPASSWD: %s restart stt-server, %s restart stt-watchdog\n' \
        "$USER" "$SYSTEMCTL_BIN" "$SYSTEMCTL_BIN" | sudo tee "$SUDOERS_FILE" > /dev/null
    sudo chmod 440 "$SUDOERS_FILE"
    if sudo visudo -cf "$SUDOERS_FILE" > /dev/null 2>&1; then
        print_success "Sudoers rule installed: $USER may restart STT services without a password"
    else
        sudo rm -f "$SUDOERS_FILE"
        print_error "Sudoers rule failed validation and was removed (auto-update will use in-place restart instead)"
    fi
}


setup_systemd_service_linux() {
    print_status "Would you like to set up a systemd service for auto-start?"
    read -p "Setup systemd service? (y/N): " -n 1 -r
    echo

    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        return
    fi

    print_status "Creating systemd service..."

    # Create service file
    sudo tee "/etc/systemd/system/$SERVICE_NAME.service" > /dev/null <<SYSEOF
[Unit]
Description=STT Speech-to-Text Server
After=network.target

[Service]
Type=simple
# The installing user, not root: a root service writes root-owned files into this
# user's checkout and ~/.stt, after which the in-app updater (running as the user)
# cannot touch them. The capability is the one thing root was for: binding port 80.
User=$USER
Group=$(id -gn)
AmbientCapabilities=CAP_NET_BIND_SERVICE
WorkingDirectory=$INSTALL_DIR
Environment="PYTHONUNBUFFERED=1"
Environment="HOME=$HOME"
ExecStart=$INSTALL_DIR/.venv/bin/python3 $INSTALL_DIR/speech_to_text.py
# No ExecStop needed: systemd SIGTERMs the whole cgroup (server, worker,
# ffmpeg) on stop — the server exits cleanly on SIGTERM — and SIGKILLs any
# straggler after TimeoutStopSec. The old pkill chain killed its own control
# process (logged as 'code=killed, status=9/KILL' on every stop).
TimeoutStopSec=15
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$SERVICE_NAME

[Install]
WantedBy=multi-user.target
SYSEOF

    # Reload systemd and enable service
    sudo systemctl daemon-reload
    sudo systemctl enable "$SERVICE_NAME"
    install_restart_sudoers

    print_success "Systemd service created and enabled"
    print_status "Service commands:"
    print_status "  sudo systemctl start $SERVICE_NAME   - Start service"
    print_status "  sudo systemctl stop $SERVICE_NAME    - Stop service"
    print_status "  sudo systemctl status $SERVICE_NAME  - Check status"
    print_status "  sudo journalctl -u $SERVICE_NAME -f  - View logs"
}


# Function to set up the watchdog service (replaces the bare stt-server service)
setup_watchdog_service() {
    if [ "$OS" = "macos" ]; then
        setup_watchdog_launchd
    else
        setup_watchdog_systemd
    fi
}

setup_watchdog_launchd() {
    local PLIST_LABEL="com.stt.watchdog"
    local PLIST_SRC="$INSTALL_DIR/deploy/com.stt.watchdog.plist"
    local PLIST_DST="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist"
    local PYTHON_BIN="$INSTALL_DIR/.venv/bin/python3"

    print_status "Would you like to set up the STT Watchdog as a LaunchAgent?"
    print_status "(Watchdog provides crash recovery and daily auto-updates)"
    read -p "Setup Watchdog LaunchAgent? (y/N): " -n 1 -r
    echo

    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        return
    fi

    mkdir -p "$HOME/Library/LaunchAgents"

    # Substitute the plist template's placeholders. launchd will not create the log
    # directory, and a job whose StandardOutPath cannot be opened never starts.
    local LOG_DIR="$HOME/.stt/logs"
    mkdir -p "$LOG_DIR"
    sed -e "s|INSTALL_DIR|$INSTALL_DIR|g" -e "s|LOG_DIR|$LOG_DIR|g" "$PLIST_SRC" > "$PLIST_DST"

    # Retire the old bare-server LaunchAgent so two supervisors don't both start
    # a server. Unloading alone isn't enough — the plist auto-loads again at the
    # next login — so remove the file too. (The 57338 server lock would keep only
    # one server alive regardless, but this avoids the losing one flapping.)
    local OLD_PLIST="$HOME/Library/LaunchAgents/com.stt.server.plist"
    if [ -f "$OLD_PLIST" ]; then
        launchctl unload "$OLD_PLIST" 2>/dev/null || true
        rm -f "$OLD_PLIST"
        print_status "Retired old com.stt.server LaunchAgent (watchdog now manages the server)"
    fi

    launchctl load "$PLIST_DST"

    print_success "Watchdog LaunchAgent installed and loaded"
    print_status "Service commands:"
    print_status "  launchctl start $PLIST_LABEL   - Start watchdog + STT"
    print_status "  launchctl stop $PLIST_LABEL    - Stop watchdog + STT"
    print_status "  launchctl unload $PLIST_DST    - Remove watchdog service"
    print_status "  tail -f $LOG_DIR/watchdog.log  - Watchdog log"
    print_status "  tail -f $LOG_DIR/stt.log       - STT app log"
}

setup_watchdog_systemd() {
    local WATCHDOG_SERVICE="stt-watchdog"
    local SERVICE_FILE="/etc/systemd/system/$WATCHDOG_SERVICE.service"

    print_status "Would you like to set up the STT Watchdog as a systemd service?"
    print_status "(Watchdog provides crash recovery and daily auto-updates)"
    read -p "Setup Watchdog systemd service? (y/N): " -n 1 -r
    echo

    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        return
    fi

    print_status "Installing $WATCHDOG_SERVICE.service..."

    sudo tee "$SERVICE_FILE" > /dev/null <<SYSEOF
[Unit]
Description=STT Watchdog (manages speech_to_text.py with crash recovery and auto-updates)
After=network.target

[Service]
Type=simple
# The installing user, not root: a root service writes root-owned files into this
# user's checkout and ~/.stt, after which the in-app updater (running as the user)
# cannot touch them. The capability is the one thing root was for: binding port 80.
User=$USER
Group=$(id -gn)
AmbientCapabilities=CAP_NET_BIND_SERVICE
WorkingDirectory=$INSTALL_DIR
Environment="PYTHONUNBUFFERED=1"
Environment="HOME=$HOME"
ExecStart=$INSTALL_DIR/.venv/bin/python3 $INSTALL_DIR/stt/watchdog.py --headless
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=$WATCHDOG_SERVICE

[Install]
WantedBy=multi-user.target
SYSEOF

    sudo systemctl daemon-reload
    sudo systemctl enable "$WATCHDOG_SERVICE"
    install_restart_sudoers

    # Always retire the old bare stt-server unit: running it alongside the
    # watchdog means two supervisors both launching a server, which is never
    # correct. (The 57338 server lock keeps only one server alive regardless,
    # but a disabled unit avoids the losing one flapping under Restart=always.)
    if systemctl list-unit-files --no-legend 'stt-server.service' 2>/dev/null | grep -q '^stt-server\.service'; then
        print_status "Retiring old stt-server.service (watchdog now manages the server)..."
        sudo systemctl stop stt-server 2>/dev/null || true
        sudo systemctl disable stt-server 2>/dev/null || true
        print_success "stt-server stopped and disabled"
    fi

    print_success "Watchdog systemd service installed and enabled"
    print_status "Service commands:"
    print_status "  sudo systemctl start $WATCHDOG_SERVICE   - Start watchdog + STT"
    print_status "  sudo systemctl stop $WATCHDOG_SERVICE    - Stop watchdog + STT"
    print_status "  sudo systemctl status $WATCHDOG_SERVICE  - Check status"
    print_status "  sudo journalctl -u $WATCHDOG_SERVICE -f  - Watchdog log"
    print_status "  tail -f $INSTALL_DIR/logs/stt.log        - STT app log"
}


# Function to display final instructions
show_final_instructions() {
    echo
    echo "========================================"
    echo "  Installation Complete!"
    echo "========================================"
    echo
    print_success "Speech-to-Text has been installed successfully"
    echo
    echo "Quick Start:"
    if [ "$OS" = "macos" ]; then
        echo "  1. Start with watchdog:  sudo python3 stt/watchdog.py --headless"
        echo "     (or GUI mode:         python3 stt/watchdog.py --gui)"
        echo "     (the shipped port is 8080, which needs no sudo; port 80 does)"
        echo "  2. Grant microphone access if prompted by macOS"
        echo "  3. Open browser: http://localhost:8080"
        echo "  4. Set microphone in \$HOME/.stt/config/config.json: default_microphone: ':0'"
        echo "     (run: ffmpeg -f avfoundation -list_devices true -i \"\" to find device index)"
    else
        echo "  1. Start with watchdog:  sudo ./start_watchdog.sh"
        echo "     (or GUI mode:         python3 stt/watchdog.py --gui)"
        echo "  2. Open browser: http://localhost:8080"
    fi
    echo "  - Go to /model-manager to download and select a model"
    echo "  - Go to /live-settings to configure audio"
    echo
    echo "Configuration:"
    # Not $INSTALL_DIR/config: that holds the shipped template. The server seeds and
    # reads its own copy in the data dir, so editing the checkout does nothing at all.
    echo "  - Main config: \$HOME/.stt/config/config.json"
    echo "    (created on first run; set STT_DATA_DIR to put the data dir elsewhere)"
    echo
    echo "Useful commands:"
    echo

    if [ "$OS" != "macos" ]; then
        if ! groups "$USER" | grep -q '\baudio\b'; then
            echo -e "${YELLOW}IMPORTANT:${NC} You were added to the 'audio' group."
            echo -e "${YELLOW}Please log out and log back in for audio permissions to take effect.${NC}"
            echo
        fi

        if [ -f "/etc/systemd/system/stt-watchdog.service" ]; then
            echo "Watchdog service installed (crash recovery + auto-updates):"
            echo "  sudo systemctl start stt-watchdog  - Start"
            echo "  sudo systemctl stop stt-watchdog   - Stop"
            echo "  sudo systemctl status stt-watchdog - Status"
            echo "  tail -f $INSTALL_DIR/logs/stt.log  - STT log"
            echo
        elif [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then
            echo "Systemd service installed:"
            echo "  sudo systemctl start $SERVICE_NAME  - Start"
            echo "  sudo systemctl stop $SERVICE_NAME   - Stop"
            echo "  sudo systemctl status $SERVICE_NAME - Status"
            echo
        fi
    fi

    echo "Manual start (with watchdog):"
    echo "  ./start_watchdog.sh          - Headless (server/production)"
    echo "  python3 stt/watchdog.py --gui    - Desktop GUI (start/stop + settings)"
    echo "  python3 stt/watchdog.py --check-update  - Check for updates now"
    echo
}

# Main installation function
main() {
    echo "========================================"
    echo "  Speech-to-Text Installation Script"
    echo "========================================"
    echo

    # Pre-flight checks
    check_not_root
    detect_os

    # Installation steps
    install_system_deps
    install_uv
    create_venv
    setup_audio_permissions
    install_python_deps
    verify_python_deps

    # Optional setups
    list_audio_devices
    test_audio_capture
    setup_watchdog_service

    # Final instructions
    show_final_instructions
}

# Handle script interruption
trap 'print_error "Installation interrupted"; exit 1' INT TERM

# Run main function
main "$@"