From 61e2c9659324181e0f0ed911958c536333b1d4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marijan=20Petri=C4=8Devi=C4=87?= Date: Tue, 5 May 2026 19:24:42 +0200 Subject: [PATCH] voxtype: initial module implementation --- modules/services/voxtype.nix | 197 ++++++++++++++++++ .../services/voxtype/basic-configuration.nix | 35 ++++ tests/modules/services/voxtype/default.nix | 5 + .../services/voxtype/expected-config.toml | 14 ++ 4 files changed, 251 insertions(+) create mode 100644 modules/services/voxtype.nix create mode 100644 tests/modules/services/voxtype/basic-configuration.nix create mode 100644 tests/modules/services/voxtype/default.nix create mode 100644 tests/modules/services/voxtype/expected-config.toml diff --git a/modules/services/voxtype.nix b/modules/services/voxtype.nix new file mode 100644 index 000000000..0e0295832 --- /dev/null +++ b/modules/services/voxtype.nix @@ -0,0 +1,197 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + getExe + makeBinPath + mapAttrsToList + mkEnableOption + mkIf + mkOption + mkPackageOption + optional + optionals + optionalAttrs + recursiveUpdate + escapeShellArg + escapeShellArgs + concatMapStringsSep + types + ; + + cfg = config.services.voxtype; + toml = pkgs.formats.toml { }; + # Voxtype requires these whenever a config file exists. + settings = recursiveUpdate { + hotkey = { }; + audio = { + device = "default"; + sample_rate = 16000; + max_duration_secs = 60; + }; + output = { + mode = "type"; + fallback_to_clipboard = true; + }; + } cfg.settings; + +in +{ + meta.maintainers = [ lib.maintainers.marijanp ]; + + options.services.voxtype = { + enable = mkEnableOption "Voxtype speech-to-text daemon"; + + package = mkPackageOption pkgs "voxtype" { + example = "pkgs.voxtype-vulkan"; + }; + + settings = mkOption { + inherit (toml) type; + default = { }; + example = { + output = { + mode = "type"; + fallback_to_clipboard = true; + }; + whisper = { + model = "base.en"; + language = "en"; + }; + }; + description = '' + Voxtype configuration written to `$XDG_CONFIG_HOME/voxtype/config.toml`. + ''; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "--verbose" ]; + description = "Extra command-line arguments passed to `voxtype daemon`."; + }; + + environment = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Environment variables for the Voxtype user service."; + }; + + x11.display = mkOption { + type = types.nullOr types.str; + default = null; + example = ":0"; + description = '' + X11 display name to expose to the Voxtype user service. + ''; + }; + + wayland.display = mkOption { + type = types.nullOr types.str; + default = null; + example = "wayland-1"; + description = '' + Wayland display socket name to expose to the Voxtype user service. + ''; + }; + + loadModels = mkOption { + type = types.listOf types.str; + apply = builtins.filter (model: model != ""); + default = [ ]; + example = [ "base.en" ]; + description = '' + Downloads the listed models with `voxtype setup --download` before starting + the daemon. + ''; + }; + }; + + config = mkIf cfg.enable { + home.packages = [ + cfg.package + ] + ++ optionals (cfg.x11.display != null) [ pkgs.xclip ] + ++ optionals (cfg.wayland.display != null) [ + pkgs.wl-clipboard + pkgs.wtype + ]; + + xdg.configFile."voxtype/config.toml" = mkIf (cfg.settings != { }) { + source = toml.generate "voxtype-config.toml" settings; + }; + + systemd.user.services.voxtype = { + Unit = { + Description = "Voxtype speech-to-text daemon"; + PartOf = [ "default.target" ]; + } + // optionalAttrs (cfg.loadModels != [ ]) { + Wants = [ "voxtype-model-loader.service" ]; + After = [ "voxtype-model-loader.service" ]; + }; + + Service = + let + runtimePath = makeBinPath ( + [ pkgs.which ] + ++ optionals (cfg.x11.display != null) [ pkgs.xclip ] + ++ optionals (cfg.wayland.display != null) [ + pkgs.wl-clipboard + pkgs.wtype + ] + ); + in + { + Type = "exec"; + ExecStart = "${getExe cfg.package} daemon ${escapeShellArgs cfg.extraArgs}"; + Restart = "on-failure"; + RestartSec = "5s"; + Environment = [ + "PATH=${runtimePath}" + "XDG_RUNTIME_DIR=%t" + ] + ++ optional (cfg.x11.display != null) "DISPLAY=${cfg.x11.display}" + ++ optional (cfg.wayland.display != null) "WAYLAND_DISPLAY=${cfg.wayland.display}" + ++ mapAttrsToList (name: value: "${name}=${value}") cfg.environment; + }; + + Install.WantedBy = [ "default.target" ]; + }; + + systemd.user.services.voxtype-model-loader = mkIf (cfg.loadModels != [ ]) { + Unit = { + Description = "Download Voxtype models"; + Before = [ "voxtype.service" ]; + Wants = [ "network-online.target" ]; + After = [ "network-online.target" ]; + }; + + Service = + let + modelLoaderScript = pkgs.writeShellScript "voxtype-model-loader" '' + set -euo pipefail + tmp="$(${pkgs.coreutils}/bin/mktemp -d /tmp/voxtype-model-loader.XXXXXX)" + trap '${pkgs.coreutils}/bin/rm -rf "$tmp"' EXIT + + ${concatMapStringsSep "\n" ( + model: + "XDG_CONFIG_HOME=\"$tmp\" ${getExe cfg.package} setup --download --model ${escapeShellArg model} --no-post-install" + ) cfg.loadModels} + ''; + in + { + Type = "oneshot"; + ExecStart = modelLoaderScript; + Restart = "on-failure"; + RestartSec = "30s"; + }; + + Install.WantedBy = [ "default.target" ]; + }; + }; +} diff --git a/tests/modules/services/voxtype/basic-configuration.nix b/tests/modules/services/voxtype/basic-configuration.nix new file mode 100644 index 000000000..b75c4ac42 --- /dev/null +++ b/tests/modules/services/voxtype/basic-configuration.nix @@ -0,0 +1,35 @@ +{ config, ... }: +{ + services.voxtype = { + enable = true; + package = config.lib.test.mkStubPackage { outPath = "@voxtype@"; }; + wayland.display = "wayland-1"; + extraArgs = [ "--verbose" ]; + environment.VOXTYPE_TEST_ENV = "1"; + settings = { + output = { + mode = "type"; + fallback_to_clipboard = true; + }; + whisper = { + model = "base.en"; + language = "en"; + }; + }; + }; + + nmt.script = '' + serviceFile=home-files/.config/systemd/user/voxtype.service + configFile=home-files/.config/voxtype/config.toml + + assertFileExists "$serviceFile" + assertFileExists "$configFile" + + assertFileRegex "$serviceFile" 'ExecStart=@voxtype@/bin/dummy daemon --verbose' + assertFileRegex "$serviceFile" 'Environment=PATH=.*/bin' + assertFileRegex "$serviceFile" 'Environment=WAYLAND_DISPLAY=wayland-1' + assertFileRegex "$serviceFile" 'Environment=VOXTYPE_TEST_ENV=1' + + assertFileContent "$configFile" ${./expected-config.toml} + ''; +} diff --git a/tests/modules/services/voxtype/default.nix b/tests/modules/services/voxtype/default.nix new file mode 100644 index 000000000..a29a84dbc --- /dev/null +++ b/tests/modules/services/voxtype/default.nix @@ -0,0 +1,5 @@ +{ lib, pkgs, ... }: + +lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + voxtype-basic-configuration = ./basic-configuration.nix; +} diff --git a/tests/modules/services/voxtype/expected-config.toml b/tests/modules/services/voxtype/expected-config.toml new file mode 100644 index 000000000..4dd272c43 --- /dev/null +++ b/tests/modules/services/voxtype/expected-config.toml @@ -0,0 +1,14 @@ +[audio] +device = "default" +max_duration_secs = 60 +sample_rate = 16000 + +[hotkey] + +[output] +fallback_to_clipboard = true +mode = "type" + +[whisper] +language = "en" +model = "base.en"