Files
2026-07-12 15:25:55 -03:00

169 lines
5.6 KiB
Nix

{ config, lib, pkgs, ... }:
let
cfg = config.custom.dictation;
# AMD GPU: Vulkan backend (works on any RADV-capable Radeon, no ROCm stack).
# Swap to { rocmSupport = true; } if you'd rather use ROCm (see notes).
whisper = pkgs.whisper-cpp.override { vulkanSupport = true; };
# Declaratively pinned GGML model. Build once with the default fakeHash and
# paste back the sha256 Nix reports.
model = pkgs.fetchurl {
url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin";
hash = cfg.modelHash;
};
port = toString cfg.port;
histRel = "dictation/history.jsonl";
dictate = pkgs.writeShellApplication {
name = "dictate-toggle";
runtimeInputs = with pkgs; [ pipewire curl jq wtype wl-clipboard libnotify coreutils ];
text = ''
# -c / --clipboard: put the transcript on the clipboard instead of typing it.
clipboard=0
case "''${1:-}" in
-c|--clipboard) clipboard=1 ;;
esac
run="''${XDG_RUNTIME_DIR:-/tmp}"
pid="$run/dictate.pid"
wav="$run/dictate.wav"
hist="''${XDG_STATE_HOME:-$HOME/.local/state}/${histRel}"
if [ -f "$pid" ] && kill -0 "$(cat "$pid")" 2>/dev/null; then
# ---- second press: stop, transcribe, type ----
kill "$(cat "$pid")" 2>/dev/null || true
rm -f "$pid"
sleep 0.2
# ignore fat-finger taps (<~0.5s @ 16kHz*2B mono = 16000 bytes)
bytes="$(stat -c%s "$wav" 2>/dev/null || echo 0)"
if [ "$bytes" -lt 16000 ]; then
notify-send -t 1000 -a dictation "🎤 too short"
rm -f "$wav"; exit 0
fi
notify-send -t 1200 -a dictation "🧠 transcribing…"
args=(-sS "http://127.0.0.1:${port}/inference"
-F file=@"$wav" -F temperature=0 -F response_format=json
-F language=${cfg.language})
result="$(curl "''${args[@]}" | jq -r '.text' \
| tr '\n' ' ' \
| sed -e 's/ */ /g' -e 's/^ *//' -e 's/ *$//')"
if [ -n "$result" ]; then
# log it (newest last), then keep only the most recent N entries
mkdir -p "$(dirname "$hist")"
jq -cn --arg ts "$(date -Is)" --arg text "$result" \
'{ts: $ts, text: $text}' >> "$hist"
tmp="$(mktemp -p "$(dirname "$hist")")"
tail -n ${toString cfg.historyMax} "$hist" > "$tmp" && mv -f "$tmp" "$hist"
if [ "$clipboard" -eq 1 ]; then
printf '%s' "$result" | wl-copy
notify-send -t 1500 -a dictation "📋 copied to clipboard"
else
wtype -- "$result"
fi
fi
rm -f "$wav"
else
# ---- first press: start recording ----
rm -f "$wav"
pw-record --rate 16000 --channels 1 --format s16 "$wav" &
echo "$!" > "$pid"
notify-send -t 1200 -a dictation "🎤 recording — F5 to stop"
fi
'';
};
history = pkgs.writeShellApplication {
name = "dictate-history";
runtimeInputs = with pkgs; [ jq rofi wl-clipboard libnotify coreutils ];
text = ''
hist="''${XDG_STATE_HOME:-$HOME/.local/state}/${histRel}"
if [ ! -s "$hist" ]; then
notify-send -t 1500 -a dictation "📋 no dictation history yet"
exit 0
fi
# newest first; keep full texts and menu rows index-aligned
mapfile -t texts < <(tac "$hist" | jq -r '.text')
mapfile -t menu < <(tac "$hist" | jq -r '
(.ts[5:16] | sub("T"; " ")) as $t
| (if (.text | length) > 60 then .text[0:60] + "…" else .text end) as $p
| "\($t) \($p)"')
idx="$(printf '%s\n' "''${menu[@]}" \
| rofi -dmenu -i -format i -p "dictation" || true)"
[[ "$idx" =~ ^[0-9]+$ ]] || exit 0
printf '%s' "''${texts[$idx]}" | wl-copy
notify-send -t 1500 -a dictation "📋 copied to clipboard"
'';
};
in
{
options.custom.dictation = {
enable = lib.mkEnableOption "local F5 push-to-talk dictation (whisper.cpp + wtype)";
model = lib.mkOption {
type = lib.types.str;
default = "large-v3";
description = ''GGML model stem, e.g. "large-v3" or "large-v3-turbo".'';
};
modelHash = lib.mkOption {
type = lib.types.str;
default = lib.fakeHash;
description = "sha256 of the model. Build once with the default, paste the reported hash.";
};
language = lib.mkOption {
type = lib.types.str;
default = "auto";
description = ''Pin a language ("en", "pt") or "auto" to detect per utterance.'';
};
port = lib.mkOption {
type = lib.types.port;
default = 8117;
};
historyMax = lib.mkOption {
type = lib.types.int;
default = 1000;
description = "Keep only the most recent N transcriptions in the history log.";
};
};
config = lib.mkIf cfg.enable {
home.packages = [ dictate history whisper ]; # whisper-cli also available for testing
systemd.user.services.whisper-server = {
Unit = {
Description = "whisper.cpp server (keeps the model warm in VRAM)";
After = [ "graphical-session.target" ];
};
Service = {
# If your build names it 'whisper-whisper-server', adjust the path below
# (nixpkgs has occasionally double-prefixed it). `ls ${whisper}/bin`.
ExecStart = lib.escapeShellArgs [
"${whisper}/bin/whisper-server"
"--model" "${model}"
"--host" "127.0.0.1"
"--port" port
"--max-len" "100000" # default 0 is silently coerced to 60; large = don't chop
];
Restart = "on-failure";
};
Install.WantedBy = [ "default.target" ];
};
};
}