voxtype: initial module implementation

This commit is contained in:
Marijan Petričević
2026-05-05 19:24:42 +02:00
committed by Matthieu Coudron
parent d17712ec7a
commit 61e2c96593
4 changed files with 251 additions and 0 deletions

View File

@@ -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" ];
};
};
}

View File

@@ -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}
'';
}

View File

@@ -0,0 +1,5 @@
{ lib, pkgs, ... }:
lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
voxtype-basic-configuration = ./basic-configuration.nix;
}

View File

@@ -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"