Add hold-to-talk voice dictation
This commit is contained in:
parent
d34ce8f4b2
commit
0efda59204
|
|
@ -5,6 +5,7 @@
|
|||
imports = [
|
||||
./direnv.nix
|
||||
./discord.nix
|
||||
./dictation.nix
|
||||
./firefox.nix
|
||||
./gpg.nix
|
||||
./fzf.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" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
{
|
||||
imports = [
|
||||
./discord.nix
|
||||
./dictation.nix
|
||||
./display.nix
|
||||
./gnomeBoxes.nix
|
||||
./gpg.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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -22,5 +22,7 @@
|
|||
|
||||
modules.discord.enable = true;
|
||||
|
||||
modules.dictation.enable = true;
|
||||
|
||||
modules.tailscale.enable = true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue