Skip to content
UnbeholdenDigital sovereignty in practice

Dictation on a laptop too slow to transcribe

I ran speech-to-text locally with Handy, but the laptop couldn't keep up. Now a short shell script records the clip and sends it to Whisper on Groq.

Linux · 10 October 2026 · 2 minutes

A vintage silver microphone on a stand, in front of a blurred crowd under blue and amber stage lights.

I press Mod+D, talk, press it again, and the text turns up at the cursor. Most of what I dictate is instructions to my agents.

For a while transcription ran on the laptop itself, the ThinkPad T460s from an earlier entry. It’s a stopgap with 8GB of memory and an old CPU, fine as a thin client but too slow for speech-to-text.

Handy, locally

Handy records you, transcribes with a local model so nothing leaves the machine, and types the result. It took a few fixes to get working under niri, but it was too slow. Even on Canary 180M, a small model, a longer clip could take 20-30 seconds to come back.

Groq instead

So I dropped Handy for a short script. The first press starts recording with ffmpeg, the second stops it, sends the clip off, and types what comes back with wtype.

I tried Gemini first. It took 3-4 seconds and on the free tier mostly answered “service overloaded”. Now the clip goes to whisper-large-v3-turbo, a dedicated speech-to-text model, on Groq. The free tier allows about 2,000 requests a day, and it’s near instant: I stop talking and the text is there. Whisper punctuates well enough that I’m trying it without a clean-up pass, an LLM tidying the text, for now.

Here it is, as the Home Manager module that installs it. The prompt is a list of words Whisper would otherwise mishear, and the API key sits in a file outside the repo.

{ pkgs, ... }:
let
  prompt = "niri, noctalia, nixpkgs, NixOS, home-manager, disko, forgejo, yazi, foot, zsh.";

  dictate = pkgs.writeShellApplication {
    name = "dictate";
    runtimeInputs = with pkgs; [
      coreutils
      curl
      ffmpeg
      libnotify
      wl-clipboard
      wtype
    ];
    text = ''
      clip="$XDG_RUNTIME_DIR/dictate.wav"
      pidfile="$XDG_RUNTIME_DIR/dictate.pid"
      log="$XDG_RUNTIME_DIR/dictate.log"

      pid=$(cat "$pidfile" 2>/dev/null || true)
      rm -f "$pidfile"
      if [ -n "$pid" ] && kill -INT "$pid" 2>/dev/null; then
        while kill -0 "$pid" 2>/dev/null; do sleep 0.05; done
      else
        notify-send -t 1000 "Dictating" || true
        ffmpeg -nostdin -loglevel error -y \
          -f pulse -i default -ac 1 -ar 16000 "$clip" &
        echo $! > "$pidfile"
        exit 0
      fi

      text=$(curl -sS --fail-with-body \
        https://api.groq.com/openai/v1/audio/transcriptions \
        -H "Authorization: Bearer $(cat "$HOME/.groq-token")" \
        -F model=whisper-large-v3-turbo \
        -F response_format=text \
        -F language=en \
        -F prompt=${pkgs.lib.escapeShellArg prompt} \
        -F file=@"$clip" | tee "$log") || text=

      rm -f "$clip"

      if [ -z "$text" ]; then
        notify-send -u critical "Dictation failed" "$log" || true
        exit 1
      fi

      printf '%s' "$text" | wl-copy
      printf '%s' "$text" | wtype -
      notify-send -t 2000 "Dictated" "$text" || true
    '';
  };
in
{
  home.packages = [ dictate ];
}

niri binds it to a key with Mod+D repeat=false { spawn "dictate"; }. The text also lands on the clipboard, in case the cursor was somewhere wtype can’t type into.

What it costs

My voice now goes to Groq every time I press the key, and dictation needs a network connection. I’m fine with both for now, since what I dictate isn’t sensitive and this laptop is temporary. When I’m on something with more memory and a faster CPU, transcription moves back onto the machine.