merge
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
{ 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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user