feat(nix): add flake package, NixOS and Home Manager modules (#936)

* feat(nix): add flake package, NixOS and Home Manager modules

Add a flake.nix plus packaging/nix derivations so Nix and NixOS users can
run and install Vocalinux declaratively, including via Home Manager.

- package.nix: buildPythonApplication with GTK3, libayatana-appindicator,
  IBus and libnotify typelibs; wraps the entry point with every host tool
  the injectors probe for (xdotool, wtype, ydotool, wl-clipboard, xclip,
  xsel, xprop, setxkbmap, ibus, gdbus, notify-send, paplay, aplay)
- pywhispercpp.nix: builds pywhispercpp from its vendored PyPI sdist
  (absent from nixpkgs, same gap as Debian/Fedora), static whisper.cpp,
  GGML_NATIVE=OFF for a portable baseline
- module.nix: NixOS module enabling hardware.uinput and installing the
  package; input/uinput group membership stays a per-user choice
- home-manager.nix: programs.vocalinux.enable installs into the profile
- docs/INSTALL.md gains a Nix / NixOS section; packaging/nix/README.md
  carries the usage snippets

Refs #931

* fix(nix): address review findings on imports check and docs

- pywhispercpp: check pywhispercpp.model and _pywhispercpp too, so the
  compiled binding is proven loadable, not just the package
- README Home Manager example: declare the nixpkgs/home-manager inputs
  and pass pkgs, which homeManagerConfiguration requires
- package.nix: use top-level xprop/setxkbmap attrs (xorg.* is deprecated)

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-10 20:40:25 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 5e97773ebb
commit c3c390f19d
9 changed files with 510 additions and 0 deletions
+4
View File
@@ -172,3 +172,7 @@ opentree.toml
# Sisyphus - local AI agent loop state (per-developer, not shared) # Sisyphus - local AI agent loop state (per-developer, not shared)
.sisyphus/ .sisyphus/
# Nix build output symlinks
result
result-*
+12
View File
@@ -10,6 +10,7 @@ How to install Vocalinux on Linux. Short overview: [project README](../README.md
| [AUR](#arch-linux-aur) | Arch / Manjaro | | [AUR](#arch-linux-aur) | Arch / Manjaro |
| [Flatpak](#flatpak) | Release `.flatpak` or local build | | [Flatpak](#flatpak) | Release `.flatpak` or local build |
| [Snap](#snap-ubuntu-snap-store) | Ubuntu Snap Store (`--edge`) or GitHub `.snap` | | [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 | | [From source](#from-source) | Contributors or custom trees |
| [Manual / PyPI](INSTALL_MANUAL.md) | Full control or pip-only workflows | | [Manual / PyPI](INSTALL_MANUAL.md) | Full control or pip-only workflows |
| [Troubleshooting](TROUBLESHOOTING.md) | Tray, audio, injection, models | | [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`). `--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 ## From source
```bash ```bash
Generated
+27
View File
@@ -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
}
+60
View File
@@ -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;
};
}
+81
View File
@@ -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.
+25
View File
@@ -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 ];
};
}
+40
View File
@@ -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.<name>.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;
};
}
+163
View File
@@ -0,0 +1,163 @@
# Vocalinux for Nix.
#
# Consumed by the repo flake (packages.<system>.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;
};
}
+98
View File
@@ -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;
};
}