#!/usr/bin/env bash # ============================================================================= # Vispr installer # ============================================================================= # # Installs Vispr on macOS (Apple silicon), Ubuntu, Debian, Arch or Omarchy: # # curl -fsSL "https://get.vispr.live/?key=YOUR-KEY" | bash # # The quotes matter: zsh, the macOS shell, treats an unquoted "?" as a # wildcard. Served without ?key=, the script asks for the access key instead # (or takes it as an argument: `bash -s -- YOUR-KEY`, or VISPR_KEY=...). # # What it does, in order. Nothing runs until the last line of this file, so a # download cut short does nothing at all. # # 1. Checks this computer: macOS on Apple silicon, or x86-64 Linux with # AVX2 on Ubuntu, Debian or Arch. Anything else stops here, unchanged. # 2. Checks the access key against downloads.vispr.live and reads that # release's manifest: file names, sizes and SHA-256 hashes. # 3. Downloads the app into a temporary folder, checks its SHA-256 against # the manifest, and installs it: # macOS Vispr.app into /Applications (no password needed) # Ubuntu/Debian the .deb with apt (sudo) # Arch/Omarchy runtime libraries with pacman, then the # binary into /usr/local (sudo) # 4. Linux only: offers the udev rule a Bluetooth DualSense needs (asks # first; sudo). # 5. Downloads the two voice models from Hugging Face into Vispr's models # folder, checking each SHA-256. A Nemotron download that Handy already # made is reused instead. No sudo; your home folder only. # 6. Opens Vispr, whose setup walks you through connecting a controller. # # Every sudo command below has a comment saying why. The temporary folder is # deleted on exit. Nothing is sent anywhere except the downloads above. # # Environment knobs: # VISPR_KEY=... the access key, if not in the URL or arguments # VISPR_NO_LAUNCH=1 install, but don't open Vispr at the end # VISPR_SKIP_CPU_CHECK=1 skip the AVX2 check (for emulated test machines; # Vispr itself still needs AVX2 to run) # # Source: apps/vispr-get/public/install.sh in the Vispr repository. # ============================================================================= set -euo pipefail # get.vispr.live writes the ?key= from the install link here, as letters, # digits and hyphens only. In the file as published, and when served without # a key, it is empty. ACCESS_KEY="" # Gated release downloads (Cloudflare R2). See docs/RELEASING.md. DOWNLOADS="https://downloads.vispr.live" # The voice models, pinned to exact revisions and hashes as in # apps/vispr-desktop/src-tauri/src/models.rs (a test there keeps these lines # in step). Format: file name|bytes|sha256|url NEMOTRON="nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf|751094240|b94545b313b3223fda7b2857a52681da813935c2127643d1e9ff0c23d988089c|https://huggingface.co/handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf/resolve/8139c4ec14bdc45c361adf8d57c27c28e7478272/nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf" S1_MINI="s1-mini-q4_k_m.gguf|484219808|3b41ebe2502cbd03e811d5d16b022f5ab551eda58d62597d152f89535003c634|https://huggingface.co/superwhisper/s1-mini-GGUF/resolve/34add00a48a2e5d24e5a4ee5405a99620a3a240c/s1-mini-q4_k_m.gguf" S1_TOKENIZER="s1-mini-tokenizer.json|11422654|aeb13307a71acd8fe81861d94ad54ab689df773318809eed3cbe794b4492dae4|https://huggingface.co/superwhisper/s1-mini/resolve/88f6b15896c73bbb13a3b596e0afe8ea0d5150b4/tokenizer.json" # ============================================================================= # Output and small helpers # ============================================================================= if [ -t 1 ]; then BOLD=$'\033[1m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' RESET=$'\033[0m' else BOLD="" GREEN="" YELLOW="" RED="" RESET="" fi step() { printf '\n%s==>%s %s%s%s\n' "$GREEN" "$RESET" "$BOLD" "$*" "$RESET"; } info() { printf ' %s\n' "$*"; } warn() { printf '%s ! %s%s\n' "$YELLOW" "$*" "$RESET"; } die() { printf '\n%serror:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; } # The script itself arrives on stdin (curl | bash), so questions are asked # on the terminal directly. Without one (CI, ssh -T), nothing is asked. have_tty() { [ -r /dev/tty ] && [ -w /dev/tty ] && { : /dev/null; } ask() { local reply printf '%s' "$1" >/dev/tty IFS= read -r reply /dev/null; then sha256sum "$@" | cut -c1-64 else shasum -a 256 "$@" | cut -c1-64; fi } file_size() { wc -c <"$1" | tr -d ' '; } # The download folder for an access key: the SHA-256 of the key, upper-cased # with everything but letters and digits removed. The key itself is never # sent; the server only sees this hash. Must match normalize() and # folderFor() in apps/vispr-site/download.js. key_folder() { printf 'vispr-download:%s' "$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -cd 'A-Z0-9')" | sha256 } # ============================================================================= # Step 1: what is this computer? # ============================================================================= # # Sets OS (Darwin or Linux), PLATFORM (macos, deb or arch), DISTRO (for # display), MODELS (where the voice models go) and, on Linux, HYPRLAND. detect() { OS="$(uname -s)" case "$OS" in Darwin) PLATFORM=macos # uname says x86_64 under Rosetta, so ask the hardware too. if [ "$(uname -m)" != arm64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" != 1 ]; then die "Vispr for macOS needs Apple silicon (an M-series Mac). This Mac has an Intel processor." fi MODELS="$HOME/Library/Application Support/vispr/models" DISTRO="macOS $(sw_vers -productVersion 2>/dev/null || true)" ;; Linux) [ "$(uname -m)" = x86_64 ] || die "Vispr for Linux needs an x86-64 processor; this one is $(uname -m)." # The speech model's kernels are built for AVX2. if [ -z "${VISPR_SKIP_CPU_CHECK:-}" ] && ! grep -qw avx2 /proc/cpuinfo; then die "Vispr needs a processor with AVX2 (most from 2015 on). This one doesn't have it." fi # shellcheck disable=SC1091 . /etc/os-release DISTRO="${PRETTY_NAME:-Linux}" case " ${ID:-} ${ID_LIKE:-} " in *" arch "*) PLATFORM=arch ;; # Arch, Omarchy, EndeavourOS... *" debian "* | *" ubuntu "*) PLATFORM=deb ;; # Ubuntu, Debian, Pop!_OS, Mint... *) die "This installer supports Ubuntu, Debian, Arch and Omarchy, and this is $DISTRO. The README explains other ways to install: https://github.com/someshkar/vispr#install" ;; esac MODELS="${XDG_DATA_HOME:-$HOME/.local/share}/vispr/models" if [ "${XDG_SESSION_TYPE:-}" = x11 ]; then warn "This is an X11 session. Vispr needs Wayland, so log in with a Wayland session before using it." fi # Hyprland lets Vispr type through its virtual-keyboard protocol, so # it doesn't need the uinput rule other desktops do. case "${XDG_CURRENT_DESKTOP:-}" in *Hyprland*) HYPRLAND=1 ;; *) HYPRLAND=0 ;; esac ;; *) die "Vispr runs on macOS and Linux; this is $OS." ;; esac } # ============================================================================= # Step 2: the access key and the release manifest # ============================================================================= # # Sets FOLDER (the key's download folder), MANIFEST (its JSON) and VERSION. fetch_manifest() { local key="$1" if [ -z "$key" ]; then have_tty || die "This needs an access key: curl -fsSL \"https://get.vispr.live/?key=YOUR-KEY\" | bash" key="$(ask " Access key: ")" fi [ -n "$(printf '%s' "$key" | tr -cd 'A-Za-z0-9')" ] || die "No access key given." FOLDER="$(key_folder "$key")" MANIFEST="$(curl -fsS "$DOWNLOADS/$FOLDER/manifest.json" 2>/dev/null)" \ || die "That access key didn't work. Check it for typos, or ask whoever gave it to you." VERSION="$(printf '%s' "$MANIFEST" | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)" } # The manifest entry for platform $1 (macos, deb or linux), as # "name|path|size|sha256". The manifest puts each file on one line. manifest_file() { printf '%s\n' "$MANIFEST" | grep "\"platform\": \"$1\"" | head -1 | sed -E \ 's/.*"name": "([^"]*)".*"path": "([^"]*)".*"size": ([0-9]+).*"sha256": "([0-9a-f]{64})".*/\1|\2|\3|\4/' } # Download the release file for platform $1 into $WORK, check its SHA-256, # and print its path. Progress goes to stderr so the path is all that's # captured. download_release() { local entry name path size sha dest entry="$(manifest_file "$1")" [ -n "$entry" ] || die "This release has no build for $1." IFS='|' read -r name path size sha <<<"$entry" dest="$WORK/$name" info "Downloading $name ($((size / 1000000)) MB)" >&2 curl -fL --retry 3 --progress-bar -o "$dest" "$DOWNLOADS/$FOLDER/$path" >&2 \ || die "The download failed. Check your connection and run the installer again." [ "$(sha256 "$dest")" = "$sha" ] || die "$name didn't match its checksum, so it wasn't installed. Run the installer again." printf '%s' "$dest" } # ============================================================================= # Step 3: installing the app # ============================================================================= # An open copy of Vispr would keep using the old files, so quit it first. quit_running() { if [ "$OS" = Darwin ]; then if pgrep -xq vispr-desktop; then info "Quitting the running copy of Vispr" osascript -e 'quit app "Vispr"' >/dev/null 2>&1 || pkill -x vispr-desktop || true sleep 1 fi elif pgrep -x vispr >/dev/null || pgrep -x vispr-desktop >/dev/null; then info "Quitting the running copy of Vispr" pkill -x vispr || true pkill -x vispr-desktop || true sleep 1 fi } # macOS: copy Vispr.app out of the disk image. Sets APP. install_macos() { local dmg mount target dmg="$(download_release macos)" mount="$WORK/mount" mkdir -p "$mount" # -nobrowse keeps the image out of Finder while it's open. hdiutil attach -quiet -nobrowse -readonly -mountpoint "$mount" "$dmg" || die "Couldn't open the disk image." # Admin accounts can write /Applications without sudo; others get # ~/Applications, which Launchpad and Spotlight also index. target=/Applications [ -w "$target" ] || { target="$HOME/Applications"; mkdir -p "$target"; } quit_running rm -rf "$target/Vispr.app" ditto "$mount/Vispr.app" "$target/Vispr.app" hdiutil detach -quiet "$mount" || true # curl doesn't quarantine what it downloads, but clear the flag in case, # so Gatekeeper doesn't stop the first launch. xattr -dr com.apple.quarantine "$target/Vispr.app" 2>/dev/null || true APP="$target/Vispr.app" info "Installed to $APP" } # Ubuntu and Debian: install the .deb. Sets BIN. install_deb() { local deb deb="$(download_release deb)" quit_running info "Installing with apt; it may ask for your password" # sudo: apt installs the package and its library dependencies system-wide. # The package adds /usr/bin/vispr, its app-menu entry and icon, and a udev # rule that lets it type on GNOME and KDE. sudo apt-get install -y "$deb" || die "apt couldn't install Vispr. Its output above says why." BIN=/usr/bin/vispr } # Arch and Omarchy: install the libraries Vispr needs, then unpack the same # .deb by hand into /usr/local. Sets BIN. (The AUR package vispr-bin does the # same through pacman, if you'd rather.) install_arch() { local deb root deb="$(download_release deb)" info "Installing what Vispr needs with pacman; it may ask for your password" # sudo: pacman installs Vispr's runtime libraries, plus patchelf and # libarchive (bsdtar), which this script uses below. --needed skips any # already installed. sudo pacman -S --needed --noconfirm \ webkit2gtk-4.1 gtk3 gtk-layer-shell libayatana-appindicator libxkbcommon alsa-lib \ opus blas cblas wl-clipboard hicolor-icon-theme patchelf libarchive \ || die "pacman couldn't install Vispr's dependencies. Its output above says why." # A .deb is an ar archive holding data.tar.*; bsdtar opens both. root="$WORK/deb" mkdir -p "$root" (cd "$root" && bsdtar -xf "$deb" && bsdtar -xf data.tar.*) || die "Couldn't unpack the package." # Built on Ubuntu, where the CBLAS functions live in libblas; Arch keeps # them in libcblas, so link that too. patchelf --add-needed libcblas.so.3 "$root/usr/bin/vispr" quit_running # sudo: /usr/local is the place for software installed outside pacman. sudo install -Dm755 "$root/usr/bin/vispr" /usr/local/bin/vispr sudo install -Dm644 "$root/usr/share/applications/vispr.desktop" /usr/local/share/applications/vispr.desktop sudo install -Dm644 "$root/usr/share/icons/hicolor/128x128/apps/vispr.png" \ /usr/local/share/icons/hicolor/128x128/apps/vispr.png sudo install -Dm644 "$root/usr/share/vispr/70-vispr-dualsense.rules" /usr/local/share/vispr/70-vispr-dualsense.rules if [ "$HYPRLAND" = 0 ]; then info "Letting Vispr type on this desktop (the uinput udev rule)" # sudo: a udev rule giving the logged-in user access to /dev/uinput, # which Vispr types through outside Hyprland. sudo install -Dm644 "$root/usr/lib/udev/rules.d/71-vispr-uinput.rules" /etc/udev/rules.d/71-vispr-uinput.rules sudo udevadm control --reload && sudo udevadm trigger --name-match=uinput || true fi if pacman -Q vispr-bin >/dev/null 2>&1; then warn "The vispr-bin package is also installed; /usr/local/bin/vispr comes first in PATH." fi BIN=/usr/local/bin/vispr info "Installed to $BIN" } # ============================================================================= # Step 4: the Bluetooth rule (Linux, asks first) # ============================================================================= # # Over Bluetooth, Vispr reads the DualSense directly, which needs a udev # rule and takes the controller off the kernel's gamepad driver while Vispr # is the one using it. Steam and SDL games are unaffected. Over USB none of # this is needed. bluetooth_rule() { local rules=/etc/udev/rules.d/70-vispr-dualsense.rules src [ "$OS" = Linux ] || return 0 src=/usr/share/vispr/70-vispr-dualsense.rules [ -f "$src" ] || src=/usr/local/share/vispr/70-vispr-dualsense.rules [ -f "$src" ] || return 0 # Already in place from an earlier run. if [ -f "$rules" ] && cmp -s "$src" "$rules"; then return 0 fi if ! have_tty; then info "For a Bluetooth DualSense, run: sudo install -m 644 $src $rules" return 0 fi case "$(ask " Will you use a DualSense over Bluetooth? It needs one more system rule. [Y/n] ")" in [nN]*) info "Skipped. To add it later: sudo install -m 644 $src $rules"; return 0 ;; esac # sudo: udev rules live in /etc/udev/rules.d, and udevadm applies them to # an already-connected controller. sudo install -m 644 "$src" "$rules" sudo udevadm control --reload || true sudo udevadm trigger --subsystem-match=hidraw --subsystem-match=input || true sudo udevadm trigger --action=bind --subsystem-match=hid || true info "Done. If the controller is already paired, turn it off and on again." } # ============================================================================= # Step 5: the voice models (no sudo; your home folder only) # ============================================================================= # Handy (handy.computer) keeps its downloads in the Hugging Face cache, as # blobs named by their SHA-256. If Nemotron is there with the right size, # Vispr finds and uses it, so it isn't downloaded twice. handy_has() { local sha="$1" bytes="$2" hub blob if [ -n "${HF_HUB_CACHE:-}" ]; then hub="$HF_HUB_CACHE" elif [ -n "${HF_HOME:-}" ]; then hub="$HF_HOME/hub" else hub="$HOME/.cache/huggingface/hub"; fi for blob in "$hub"/models--handy-computer--*/blobs/"$sha"; do [ -f "$blob" ] && [ "$(file_size "$blob")" = "$bytes" ] && return 0 done return 1 } # Download one catalog entry ($1, "file|bytes|sha256|url") into $MODELS. # It downloads to a .part file, which a rerun resumes, and only takes the # real name once the size and SHA-256 match. get_model() { local file bytes sha url dest part IFS='|' read -r file bytes sha url <<<"$1" dest="$MODELS/$file" if [ -f "$dest" ] && [ "$(file_size "$dest")" = "$bytes" ]; then info "$file is already here" return 0 fi part="$dest.part" info "Downloading $file ($((bytes / 1000000)) MB)" curl -fL --retry 3 --progress-bar -C - -o "$part" "$url" \ || die "Downloading $file failed. Run the installer again; it picks up where it stopped." if [ "$(file_size "$part")" != "$bytes" ] || [ "$(sha256 "$part")" != "$sha" ]; then rm -f "$part" die "$file didn't match its checksum, so it was deleted. Run the installer again." fi mv "$part" "$dest" } install_models() { local need=0 avail reuse=0 nemotron_bytes nemotron_sha mkdir -p "$MODELS" IFS='|' read -r _ nemotron_bytes nemotron_sha _ <<<"$NEMOTRON" if handy_has "$nemotron_sha" "$nemotron_bytes"; then reuse=1 info "Reusing Handy's copy of Nemotron 3.5" else need=$((need + nemotron_bytes)) fi need=$((need + 500000000)) # S1-mini and its tokenizer, rounded up # Check the space up front rather than failing halfway through. avail="$(df -Pk "$MODELS" | awk 'NR == 2 { print $4 * 1024 }')" [ "${avail:-0}" -gt "$need" ] \ || die "The models need about $((need / 1000000)) MB free in $MODELS, and there's $((avail / 1000000)) MB." [ "$reuse" = 1 ] || get_model "$NEMOTRON" get_model "$S1_MINI" get_model "$S1_TOKENIZER" } # ============================================================================= # Step 6: opening Vispr # ============================================================================= launch() { if [ -n "${VISPR_NO_LAUNCH:-}" ]; then info "Not opening Vispr (VISPR_NO_LAUNCH is set)." return 0 fi if [ "$OS" = Darwin ]; then open "$APP" return 0 fi if [ -z "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then info "No desktop session here, so Vispr wasn't opened. Open it from your app launcher." return 0 fi # Run it as its own systemd user unit, so closing this terminal doesn't # take Vispr with it; setsid where there's no systemd. if command -v systemd-run >/dev/null \ && systemd-run --user --quiet --collect --unit="vispr-$$" "$BIN" >/dev/null 2>&1; then return 0 fi (setsid "$BIN" >/dev/null 2>&1 &) } # ============================================================================= # Main # ============================================================================= main() { # The key from the install link wins, then an argument, then VISPR_KEY # (VISPR_CODE is its older name); with none, fetch_manifest asks. local key="${ACCESS_KEY:-${1:-${VISPR_KEY:-${VISPR_CODE:-}}}}" printf '%sVispr installer%s\n' "$BOLD" "$RESET" detect info "$DISTRO" step "Checking your access key" fetch_manifest "$key" info "Vispr $VERSION" # Downloads go to a temporary folder, removed on exit. apt reads the # package as its own unprivileged user, so the folder must be readable. WORK="$(mktemp -d)" chmod 755 "$WORK" trap 'hdiutil detach -quiet "$WORK/mount" 2>/dev/null || true; rm -rf "$WORK"' EXIT step "Installing Vispr" case "$PLATFORM" in macos) install_macos ;; deb) install_deb ;; arch) install_arch ;; esac bluetooth_rule if [ "$OS" = Linux ] && [ "$(command -v vispr || true)" != "$BIN" ]; then warn "Another copy of Vispr comes first in your PATH: $(command -v vispr || echo none)." warn "Remove it so your app launcher opens this one." fi step "Downloading the voice models" info "Speech: Nemotron 3.5. Formatting: S1-mini by Superwhisper. Both run on this computer." install_models step "Opening Vispr" launch printf '\n%sVispr is installed, and its models are downloaded.%s\n' "$BOLD" "$RESET" info "Setup opens next: connect a DualSense or your phone, then try the short tour." if [ "$OS" = Darwin ]; then info "macOS will ask to let Vispr use the microphone and control this Mac (Accessibility)." fi } main "$@"