From 0efda5920470fb539950831ced7fbd799c3666c0 Mon Sep 17 00:00:00 2001 From: Joshua Yuen Date: Sat, 12 Sep 2026 12:46:30 -0400 Subject: [PATCH] Add hold-to-talk voice dictation --- modules/home/default.nix | 1 + modules/home/dictation.nix | 128 ++++++++++++++++++++++++++++++++++ modules/options/default.nix | 1 + modules/options/dictation.nix | 24 +++++++ profiles/desktop/default.nix | 2 + 5 files changed, 156 insertions(+) create mode 100644 modules/home/dictation.nix create mode 100644 modules/options/dictation.nix diff --git a/modules/home/default.nix b/modules/home/default.nix index 1c84110..9e9faec 100644 --- a/modules/home/default.nix +++ b/modules/home/default.nix @@ -5,6 +5,7 @@ imports = [ ./direnv.nix ./discord.nix + ./dictation.nix ./firefox.nix ./gpg.nix ./fzf.nix diff --git a/modules/home/dictation.nix b/modules/home/dictation.nix new file mode 100644 index 0000000..294a68c --- /dev/null +++ b/modules/home/dictation.nix @@ -0,0 +1,128 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; +let + cfg = config.modules.dictation; + hyprlandCfg = config.modules.hyprland; + + models = { + "tiny.en" = pkgs.fetchurl { + url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin"; + hash = "sha256-kh5M+Ghv3Zk9zQgaXaW2w2W/3hFi5ysI11rHUomSCx8="; + }; + "base.en" = pkgs.fetchurl { + url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"; + hash = "sha256-oDd5yG3zMjB19eeWyyzlAp8A7Ihp7uP9+4l6/jbG0AI="; + }; + "small.en" = pkgs.fetchurl { + url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin"; + hash = "sha256-xhONbVjsyDIgl+D5h8MvG+i7ChhTKj+I9zTRu/nEHl0="; + }; + }; + + runtimeDeps = with pkgs; [ + coreutils + gnused + libnotify + pipewire + whisper-cpp + wtype + ]; + + dictation = pkgs.writeShellScriptBin "dictation" '' + PATH=${lib.makeBinPath runtimeDeps}:''${PATH:-} + set -euo pipefail + + model="${models.${cfg.model}}" + + dir="''${XDG_RUNTIME_DIR:-/tmp}/dictation" + mkdir -p "$dir" + pidfile="$dir/pid" + wav="$dir/clip.wav" + + notify() { + notify-send -t 2500 "Dictation" "$1" 2>/dev/null || true + } + + case "''${1:-}" in + start) + if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then + exit 0 + fi + rm -f "$pidfile" "$wav" + notify "Listening..." + pw-record --rate 16000 --channels 1 --format s16 "$wav" >/dev/null 2>&1 & + pid=$! + echo "$pid" > "$pidfile" + disown + sleep 0.3 + if ! kill -0 "$pid" 2>/dev/null; then + rm -f "$pidfile" + notify "Microphone unavailable" + fi + ;; + stop) + [ -f "$pidfile" ] || exit 0 + pid="$(cat "$pidfile")" + rm -f "$pidfile" + if kill -0 "$pid" 2>/dev/null; then + kill -INT "$pid" 2>/dev/null || true + for _ in $(seq 1 40); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.05 + done + kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true + fi + sleep 0.1 + + if [ ! -f "$wav" ]; then + notify "Recording missing" + exit 0 + fi + # Ignore clips shorter than 0.5s (16 kHz, s16, mono: 32000 B/s + 44 B header) + size="$(stat -c %s "$wav" 2>/dev/null || echo 0)" + if [ "$size" -lt 16044 ]; then + rm -f "$wav" + exit 0 + fi + + if ! text="$(whisper-cli -np -nt -t "$(nproc)" -m "$model" -f "$wav" 2>/dev/null \ + | sed -e 's/([^)]*)//g' -e 's/\[[^]]*\]//g' \ + | tr '\n' ' ' \ + | sed -e 's/ */ /g' -e 's/^ *//' -e 's/ *$//')"; then + rm -f "$wav" + notify "Transcription failed" + exit 1 + fi + rm -f "$wav" + + if [ -z "$text" ]; then + notify "No speech detected" + exit 0 + fi + + wtype -- "$text" || notify "Could not type transcription" + ;; + *) + echo "usage: dictation {start|stop}" >&2 + exit 1 + ;; + esac + ''; +in +{ + config = mkIf cfg.enable { + home.packages = [ dictation ]; + + wayland.windowManager.hyprland = mkIf hyprlandCfg.enable { + settings = { + bind = [ "SUPER, T, exec, dictation start" ]; + bindr = [ "SUPER, T, exec, dictation stop" ]; + }; + }; + }; +} diff --git a/modules/options/default.nix b/modules/options/default.nix index 34f71db..b7e1f7b 100644 --- a/modules/options/default.nix +++ b/modules/options/default.nix @@ -4,6 +4,7 @@ { imports = [ ./discord.nix + ./dictation.nix ./display.nix ./gnomeBoxes.nix ./gpg.nix diff --git a/modules/options/dictation.nix b/modules/options/dictation.nix new file mode 100644 index 0000000..fff43aa --- /dev/null +++ b/modules/options/dictation.nix @@ -0,0 +1,24 @@ +{ + lib, + ... +}: +with lib; +{ + options.modules.dictation = { + enable = mkEnableOption { + default = false; + description = "Enable hold-to-talk voice dictation (hold SUPER+T)"; + }; + model = mkOption { + type = types.enum [ + "tiny.en" + "base.en" + "small.en" + ]; + default = "base.en"; + description = '' + The local Whisper model to use for transcription. + ''; + }; + }; +} diff --git a/profiles/desktop/default.nix b/profiles/desktop/default.nix index fbdaad2..74d72a2 100644 --- a/profiles/desktop/default.nix +++ b/profiles/desktop/default.nix @@ -22,5 +22,7 @@ modules.discord.enable = true; + modules.dictation.enable = true; + modules.tailscale.enable = true; }