169 lines
5.6 KiB
Nix
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" ];
|
|
};
|
|
};
|
|
}
|