diff --git a/.gitignore b/.gitignore index f5db04c..5cc8da6 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,7 @@ opentree.toml # Sisyphus - local AI agent loop state (per-developer, not shared) .sisyphus/ + +# Nix build output symlinks +result +result-* diff --git a/docs/INSTALL.md b/docs/INSTALL.md index fd48248..43a5253 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -10,6 +10,7 @@ How to install Vocalinux on Linux. Short overview: [project README](../README.md | [AUR](#arch-linux-aur) | Arch / Manjaro | | [Flatpak](#flatpak) | Release `.flatpak` or local build | | [Snap](#snap-ubuntu-snap-store) | Ubuntu Snap Store (`--edge`) or GitHub `.snap` | +| [Nix](#nix--nixos) | Flake package, Home Manager, or the NixOS module | | [From source](#from-source) | Contributors or custom trees | | [Manual / PyPI](INSTALL_MANUAL.md) | Full control or pip-only workflows | | [Troubleshooting](TROUBLESHOOTING.md) | Tray, audio, injection, models | @@ -185,6 +186,17 @@ sudo snap connect vocalinux:uinput `--dangerous` is required because this file is not a Store revision. It will not refresh from the Store. After Canonical grants `uinput`, switch to `sudo snap install vocalinux --edge` (or `snap refresh`). +## Nix / NixOS + +The repo is a flake: GTK, PyGObject and the AppIndicator typelib come from nixpkgs, and `pywhispercpp` (absent from nixpkgs, same gap as Debian and Fedora) is built from its vendored PyPI sdist. The wrapped binary carries every injector and clipboard tool on PATH. + +```bash +nix run github:VocaHQ/vocalinux # try it +nix profile install github:VocaHQ/vocalinux +``` + +On NixOS, `vocalinux.nixosModules.vocalinux` provides `programs.vocalinux.enable`, which installs the package and enables `hardware.uinput`. Add each dictating user to `input` and `uinput` for Wayland shortcuts and ydotool typing. Home Manager users get `vocalinux.homeManagerModules.vocalinux` with the same `programs.vocalinux` options — device permissions stay on the system side. Usage snippets and scope notes: [packaging/nix/README.md](../packaging/nix/README.md). + ## From source ```bash diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..416bfe1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791456040, + "narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..dcf4b82 --- /dev/null +++ b/flake.nix @@ -0,0 +1,60 @@ +{ + description = "Vocalinux — free, offline voice dictation for Linux"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + packages = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + vocalinux = pkgs.python3Packages.callPackage ./packaging/nix/package.nix { + pywhispercpp = pkgs.python3Packages.callPackage ./packaging/nix/pywhispercpp.nix { }; + src = self; + }; + default = self.packages.${system}.vocalinux; + } + ); + + apps = forAllSystems (system: { + vocalinux = { + type = "app"; + program = "${self.packages.${system}.vocalinux}/bin/vocalinux"; + }; + default = self.apps.${system}.vocalinux; + }); + + overlays.default = final: prev: { + vocalinux = self.packages.${prev.system}.vocalinux; + }; + + nixosModules.vocalinux = + { pkgs, lib, ... }: + { + imports = [ ./packaging/nix/module.nix ]; + programs.vocalinux.package = lib.mkDefault self.packages.${pkgs.system}.vocalinux; + }; + nixosModules.default = self.nixosModules.vocalinux; + + homeManagerModules.vocalinux = + { pkgs, lib, ... }: + { + imports = [ ./packaging/nix/home-manager.nix ]; + programs.vocalinux.package = lib.mkDefault self.packages.${pkgs.system}.vocalinux; + }; + homeManagerModules.default = self.homeManagerModules.vocalinux; + }; +} diff --git a/packaging/nix/README.md b/packaging/nix/README.md new file mode 100644 index 0000000..64ea09e --- /dev/null +++ b/packaging/nix/README.md @@ -0,0 +1,81 @@ +# Vocalinux for Nix + +A flake that builds Vocalinux against nixpkgs: GTK 3, PyGObject and the +AppIndicator typelib come from nixpkgs, and `pywhispercpp` — which nixpkgs +does not package — is built from its PyPI sdist (it vendors whisper.cpp and +pybind11). Every host tool the app probes for (`xdotool`, `wtype`, `ydotool`, +`wl-clipboard`, `xclip`, `xsel`, `xprop`, `setxkbmap`, `ibus`, `gdbus`, +`notify-send`, `paplay`, `aplay`) is wired onto PATH, so the wrapped binary +behaves like a deb/rpm install with Recommends present. + +## Try it + +```bash +nix run github:VocaHQ/vocalinux +``` + +or install into a profile: + +```bash +nix profile install github:VocaHQ/vocalinux +``` + +## NixOS + +```nix +{ + inputs.vocalinux.url = "github:VocaHQ/vocalinux"; + + outputs = { nixpkgs, vocalinux, ... }: { + nixosConfigurations.host = nixpkgs.lib.nixosSystem { + modules = [ + vocalinux.nixosModules.vocalinux + { + programs.vocalinux.enable = true; + users.users.you.extraGroups = [ "input" "uinput" ]; + } + ]; + }; + }; +} +``` + +`hardware.uinput` (enabled by the module) covers `/dev/uinput`, which ydotool +and the evdev backend's keyboard clone need. `input` group membership is a +per-user decision — it grants read access to every keyboard and pointer +device, the same trade-off `install.sh` documents. X11 sessions need neither +and can skip the group lines entirely. + +## Home Manager + +```nix +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.home-manager.url = "github:nix-community/home-manager"; + inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs"; + inputs.vocalinux.url = "github:VocaHQ/vocalinux"; + + outputs = { nixpkgs, home-manager, vocalinux, ... }: { + homeConfigurations.you = home-manager.lib.homeManagerConfiguration { + pkgs = nixpkgs.legacyPackages.x86_64-linux; + modules = [ + vocalinux.homeManagerModules.vocalinux + { programs.vocalinux.enable = true; } + ]; + }; + }; +} +``` + +Home Manager on NixOS still needs the system-side pieces above (the NixOS +module handles `hardware.uinput`; group membership stays per-user). On a +non-NixOS host, `nix profile install` plus the equivalent udev rule and group +membership from your distro does the same job — see docs/INSTALL.md. + +## Scope + +- Default engine whisper.cpp plus the `[vad]` extra (ONNX Runtime) are built + in. The `vosk`, `whisper`, `parakeet` and `faster_whisper` extras are not + packaged (PyTorch footprint / not in nixpkgs). +- `pywhispercpp` is built with `GGML_NATIVE=OFF` — a portable baseline rather + than `-march=native`, so binaries can move between machines. diff --git a/packaging/nix/home-manager.nix b/packaging/nix/home-manager.nix new file mode 100644 index 0000000..e4f3e75 --- /dev/null +++ b/packaging/nix/home-manager.nix @@ -0,0 +1,25 @@ +# Home Manager module for Vocalinux. +# +# Installs the package into the user profile; device permissions are a +# system concern, so pair it with the NixOS module (or the manual group +# steps in docs/INSTALL.md) for Wayland/evdev shortcuts and ydotool typing. +{ config, lib, pkgs, ... }: + +let + cfg = config.programs.vocalinux; +in +{ + options.programs.vocalinux = { + enable = lib.mkEnableOption "Vocalinux voice dictation"; + + package = lib.mkOption { + type = lib.types.package; + defaultText = "the package built by the vocalinux flake"; + description = "The vocalinux package to install."; + }; + }; + + config = lib.mkIf cfg.enable { + home.packages = [ cfg.package ]; + }; +} diff --git a/packaging/nix/module.nix b/packaging/nix/module.nix new file mode 100644 index 0000000..0473e2e --- /dev/null +++ b/packaging/nix/module.nix @@ -0,0 +1,40 @@ +# NixOS module for Vocalinux. +# +# The flake wraps this file and injects the flake-built package as the +# default (programs.vocalinux.package), so importing the module through +# nixosModules.vocalinux needs no extra wiring. Imported on its own, set +# programs.vocalinux.package to a vocalinux derivation. +# +# What it cannot do for you: membership in the "input" group is per-user, +# so the module documents it and each user opts in: +# +# users.users..extraGroups = [ "input" "uinput" ]; +# +# "input" grants read access to every keyboard and pointer device — the +# evdev global-shortcut backend needs it on Wayland. X11 sessions need none +# of this. See docs/INSTALL.md for the same notes the deb/rpm carry. +{ config, lib, pkgs, ... }: + +let + cfg = config.programs.vocalinux; +in +{ + options.programs.vocalinux = { + enable = lib.mkEnableOption "Vocalinux voice dictation"; + + package = lib.mkOption { + type = lib.types.package; + defaultText = "the package built by the vocalinux flake"; + description = "The vocalinux package to install."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + # /dev/uinput is what ydotool (native Wayland typing) and the evdev + # backend's keyboard clone open. The option loads the module, creates + # the "uinput" group and installs the matching udev rule. + hardware.uinput.enable = true; + }; +} diff --git a/packaging/nix/package.nix b/packaging/nix/package.nix new file mode 100644 index 0000000..79db806 --- /dev/null +++ b/packaging/nix/package.nix @@ -0,0 +1,163 @@ +# Vocalinux for Nix. +# +# Consumed by the repo flake (packages..vocalinux) and buildable with +# plain callPackage: +# +# pkgs.python3Packages.callPackage ./packaging/nix/package.nix { +# pywhispercpp = pkgs.python3Packages.callPackage ./packaging/nix/pywhispercpp.nix { }; +# src = ./.; +# } +# +# Layout mirrors packaging/native/nfpm.yaml: the interpreter, GTK, PyGObject +# and the AppIndicator typelib come from nixpkgs, pywhispercpp is built from +# its vendored sdist, and every host tool the injectors probe for +# (shutil.which) is wired onto PATH so the packaged binary behaves like the +# deb/rpm install with Recommends installed. +{ + lib, + buildPythonApplication, + setuptools, + gobject-introspection, + wrapGAppsHook3, + gtk3, + libayatana-appindicator, + ibus, + libnotify, + glib, + # Python dependencies (resolved from python3Packages scope). + pywhispercpp, + pynput, + evdev, + requests, + pysocks, + numpy, + pyaudio, + pygobject3, + pycairo, + psutil, + onnxruntime, + tqdm, + platformdirs, + # Host binaries the app probes for with shutil.which at runtime. + xdotool, + wtype, + ydotool, + wl-clipboard, + xclip, + xsel, + xprop, + setxkbmap, + pulseaudio, + alsa-utils, + src, +}: + +let + # Host tools the injectors, clipboard fallbacks, audio cues and layout + # probing shell out to. Prepending them mirrors installing the deb/rpm + # Recommends set. gdbus comes from glib.bin; ibus covers ibus/ibus-daemon. + runtimeTools = [ + xdotool + wtype + ydotool # provides ydotoold too + wl-clipboard # wl-copy / wl-paste + xclip + xsel + xprop + setxkbmap + ibus + libnotify # notify-send + pulseaudio # paplay + alsa-utils # aplay + (lib.getBin glib) # gdbus + ]; +in +buildPythonApplication { + pname = "vocalinux"; + + # Track src/vocalinux/version.py so release bumps need no edit here. + # builtins.match is POSIX ERE (no \s class), so flatten newlines first. + version = + let + match = builtins.match ''.*__version__ = "([^"]+)".*'' ( + builtins.replaceStrings [ "\n" ] [ " " ] ( + builtins.readFile (src + "/src/vocalinux/version.py") + ) + ); + in + if match == null then "0.0.0" else builtins.head match; + + inherit src; + pyproject = true; + + nativeBuildInputs = [ + # Registers GI_TYPELIB_PATH for every buildInput shipping typelibs, and + # wraps the entry point with the GLib/GTK environment. + gobject-introspection + wrapGAppsHook3 + ]; + + buildInputs = [ + gtk3 # Gtk-3.0 + libayatana-appindicator # AyatanaAppIndicator3-0.1 (+ AppIndicator3 fallback) + ibus # IBus-1.0 typelib for the Wayland injection engine + libnotify # Notify-0.7 + ]; + + build-system = [ setuptools ]; + + dependencies = [ + pywhispercpp # default whisper.cpp engine; vendored sdist build + pynput # X11 hotkey backend + evdev # Wayland/evdev hotkey backend + uinput clone + requests + pysocks + numpy + pyaudio + pygobject3 + pycairo + psutil + onnxruntime # [vad] extra: Silero VAD + tqdm # model download progress (pywhispercpp also depends on it) + platformdirs + ]; + + # Optional engines are intentionally not packaged: [whisper] pulls PyTorch, + # and vosk/sherpa-onnx/faster-whisper are not in nixpkgs. The whisper.cpp + # engine remains the default; other engines can be layered on with an + # overlay later. + + preFixup = '' + gappsWrapperArgs+=( + --prefix PATH : ${lib.makeBinPath runtimeTools} + ) + ''; + + postInstall = '' + install -Dm644 vocalinux.desktop \ + $out/share/applications/vocalinux.desktop + install -Dm644 resources/icons/scalable/vocalinux.svg \ + $out/share/icons/hicolor/scalable/apps/vocalinux.svg + ''; + + dontCheck = true; # test suite needs a session bus and X display + + pythonImportsCheck = [ "vocalinux" ]; + + meta = { + description = "Free, offline voice dictation for Linux"; + longDescription = '' + Hold a shortcut or toggle dictation and text lands in almost any app on + X11 or Wayland. Speech recognition runs locally with whisper.cpp after + downloading a model on first use. + + Wayland/evdev shortcuts and ydotool injection need device access: on + NixOS enable hardware.uinput and add your user to the "input" and + "uinput" groups; the programs.vocalinux NixOS module does the first part. + ''; + homepage = "https://github.com/VocaHQ/vocalinux"; + license = lib.licenses.agpl3Only; + mainProgram = "vocalinux"; + platforms = lib.platforms.linux; + }; +} diff --git a/packaging/nix/pywhispercpp.nix b/packaging/nix/pywhispercpp.nix new file mode 100644 index 0000000..6e3de6e --- /dev/null +++ b/packaging/nix/pywhispercpp.nix @@ -0,0 +1,98 @@ +# pywhispercpp is not in nixpkgs (same gap the .deb/.rpm note in +# packaging/native/nfpm.yaml calls out for Debian and Fedora), so the flake +# builds it here. The PyPI sdist already vendors whisper.cpp and pybind11, so +# no extra source fetches are needed; setup.py drives CMake from build_ext. +{ + lib, + buildPythonPackage, + fetchPypi, + setuptools, + wheel, + cmake, + numpy, + requests, + tqdm, + platformdirs, + autoPatchelfHook, + stdenv, +}: + +buildPythonPackage rec { + pname = "pywhispercpp"; + version = "1.5.1"; + pyproject = true; + + src = fetchPypi { + inherit pname version; + hash = "sha256-XfiX5d2dnxaAT8k3vYW59YgKCymlX4hDWF4OA9dhleU="; + }; + + # python -m build verifies every build-system.requires entry resolves to an + # installed Python package. The ones setup.py never imports are dropped: + # ninja is try/except'd (Unix Makefiles are the fallback), repairwheel only + # runs on Windows, setuptools-scm is unused, and cmake is a PATH tool, not a + # Python package. + postPatch = '' + substituteInPlace pyproject.toml \ + --replace-fail ' "ninja",' "" \ + --replace-fail ' "cmake>=3.12",' "" \ + --replace-fail ' "repairwheel",' "" \ + --replace-fail ' "setuptools-scm>=8"' "" + + # setup() passes no version, so without setuptools-scm the wheel metadata + # lands as 0.0.0; get_version() reads PYWHISPERCPP_VERSION instead. + substituteInPlace setup.py \ + --replace-fail ' name="pywhispercpp",' ' name="pywhispercpp", version=get_version(),' + ''; + + env = { + # The sdist ships no version.txt; setup.py falls back to this env var. + PYWHISPERCPP_VERSION = version; + # One static extension module (BUILD_SHARED_LIBS defaults ON upstream and + # would need $ORIGIN rpath repair that never runs outside cibuildwheel) and + # a portable baseline instead of -march=native. + CMAKE_ARGS = "-DBUILD_SHARED_LIBS=OFF -DGGML_NATIVE=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON"; + }; + + # env attrs are exported verbatim; expanding the core count needs a shell + # phase. + preBuild = '' + export CMAKE_BUILD_PARALLEL_LEVEL="$NIX_BUILD_CORES" + ''; + + # cmake is only here so setup.py's build_ext can invoke it; letting the + # cmake configure hook run standalone leaves a build/ dir that breaks the + # pypa wheel build. + dontUseCmakeConfigure = true; + + nativeBuildInputs = [ + cmake + autoPatchelfHook + ]; + + build-system = [ + setuptools + wheel + ]; + + # libstdc++/libgomp for the compiled extension. + buildInputs = [ stdenv.cc.cc.lib ]; + + dependencies = [ + numpy + requests + tqdm + platformdirs + ]; + + # pywhispercpp.model pulls in the compiled _pywhispercpp extension, so + # checking all three proves the native binding loads, not just the package. + pythonImportsCheck = [ "pywhispercpp" "pywhispercpp.model" "_pywhispercpp" ]; + + meta = { + description = "Python bindings for whisper.cpp"; + homepage = "https://github.com/absadiki/pywhispercpp"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + }; +}