{ 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() { # -a sets the app name shown by the notification daemon; the bar # treats "Dictation" as transient (toast only, never listed) notify-send -a "Dictation" -t 2500 "$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" ]; }; }; }; }