Toggle theme
Jovan Zatezalo
Engineering

Build a Private Voice-to-Text & AI Rewrite Toolkit on macOS

A fully local, private text-and-voice toolkit for a Mac — global-hotkey dictation with Whisper and one-keystroke AI rewriting with a local LLM. No subscriptions, no API keys, nothing leaves your machine.

Jovan ZatezaloJuly 14, 202614 min read

I wanted two things on my Mac that normally cost a subscription and send your data to someone else's servers: voice dictation that understands code, and a one-keystroke AI rewrite for emails and messages. So I built both to run entirely on-device. No cloud, no API keys, no per-request cost — and it works offline.

This is the full guide: what it does, how it's wired together, and every gotcha I hit so you don't have to.

What you'll build

Three features that live behind global hotkeys and right-click menus:

  1. Read Aloud — select any text and have macOS speak it (built in, zero setup).
  2. Voice dictation — hold a key chord, talk, and your speech is transcribed locally by Whisper and pasted wherever your cursor is. Great with code and technical terms.
  3. AI Rewrite — select text, press a hotkey, and a local LLM rewrites it (Professional / Shorter / Longer / Casual / Prompt) and drops it on your clipboard.

Everything runs on-device. Dictation uses whisper.cpp; rewriting uses Ollama.

Built and tested on: MacBook Pro (Apple Silicon, 48 GB RAM), macOS. Any modern Apple-Silicon Mac works; more RAM lets you run bigger rewrite models.

How the pieces fit together

Before the setup, here's the mental model. Two small scripts do the work, and two system tools (Hammerspoon and Shortcuts) give them global triggers.

flowchart TD
  A[Global hotkey chord] -->|Hammerspoon| B[record.sh]
  B -->|sox records mic| C[recording.wav]
  C -->|whisper.cpp transcribes| D[Clipboard]
  D -->|auto Cmd+V| E[Text at your cursor]
 
  F[Select text + hotkey] -->|Shortcuts Quick Action| G[rewrite.sh]
  G -->|local LLM via Ollama| H[Clipboard]
  H -->|you press Cmd+V| I[Rewritten text]

Prerequisites

  • macOS Sonoma or later.
  • Homebrew — if you don't have it:
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  • Xcode Command Line Tools (gives you git, python3):
    xcode-select --install

Verify everything is reachable:

brew --version
git --version
python3 --version

Pick a home for the scripts. This guide uses ~/bin — that choice matters later (see the gotchas around macOS file protections):

mkdir -p ~/bin

Part 1 — Read Aloud (2 minutes, no code)

This is already built into macOS.

  1. System Settings → Accessibility → Spoken Content → turn on Speak selection.
  2. (Optional) Set a shortcut key and pick a voice/speed under the same panel.
  3. Test: select a paragraph anywhere → right-click → Speech → Start Speaking.

Done. On to the parts with actual code.

Part 2 — Local voice dictation with Whisper

The flow: press a global key chord → speak → press the chord again → whisper.cpp transcribes locally → the text is pasted at your cursor.

2.1 Install the tools

brew install sox whisper-cpp
  • sox records the microphone.
  • whisper-cpp runs Whisper locally (Metal-accelerated on Apple Silicon).

⚠️ Gotcha #1: Homebrew's whisper-cpp formula installs the CLI as whisper-cli, not whisper-cpp. Check with which whisper-cli.

2.2 Download a model

For technical vocabulary, use a large model. large-v3-turbo is the sweet spot — nearly the accuracy of large-v3 at a fraction of the latency:

mkdir -p ~/.whisper-models
curl -L -o ~/.whisper-models/ggml-large-v3-turbo.bin \
  https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin

(~1.6 GB. Prefer maximum accuracy over speed? Swap large-v3-turbo for large-v3, ~3 GB.)

2.3 The recording script

Create ~/bin/record.sh. It's a toggle: first run starts recording, second run stops and transcribes. A lock file is the single source of truth, so it can't get out of sync.

#!/bin/bash
#
# record.sh — toggle voice recording + local Whisper transcription.
#   1st run: starts recording the microphone to a temp .wav (sox).
#   2nd run: stops recording, transcribes with whisper.cpp, copies text to clipboard.
 
set -uo pipefail
 
# Ensure Homebrew's bin dir is on PATH (Hammerspoon launches with a minimal PATH).
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
 
MODEL="${WHISPER_MODEL:-$HOME/.whisper-models/ggml-large-v3-turbo.bin}"
WHISPER_BIN="${WHISPER_BIN:-/opt/homebrew/bin/whisper-cli}"
SOX_BIN="${SOX_BIN:-/opt/homebrew/bin/sox}"
 
WORKDIR="/tmp/whisper_dictation"
WAV="$WORKDIR/recording.wav"
LOCK="$WORKDIR/record.pid"
LOGFILE="$WORKDIR/record.log"
 
mkdir -p "$WORKDIR"
log() { echo "$(date '+%H:%M:%S') [record.sh] $*" | tee -a "$LOGFILE" >&2; }
log "=== invoked (lock exists: $([[ -f "$LOCK" ]] && echo yes || echo no)) ==="
 
# Self-heal: a lock whose sox process isn't running is stale — clear it.
if [[ -f "$LOCK" ]]; then
    STALE_PID="$(cat "$LOCK" 2>/dev/null)"
    if ! kill -0 "$STALE_PID" 2>/dev/null; then
        log "Stale lock found (pid $STALE_PID not running) — clearing it."
        rm -f "$LOCK"
    fi
fi
 
# ---- STOP branch: a recording is already in progress ----------------------
if [[ -f "$LOCK" ]]; then
    REC_PID="$(cat "$LOCK")"
    log "Stopping recording (pid $REC_PID)…"
    kill "$REC_PID" 2>/dev/null
    pkill -f "$SOX_BIN -q" 2>/dev/null   # kill any stray recorders
    sleep 0.2
    rm -f "$LOCK"
 
    if [[ ! -s "$WAV" ]]; then
        log "ERROR: no audio was captured ($WAV is empty)."
        echo "STATUS:NOAUDIO"; exit 1
    fi
    if [[ ! -f "$MODEL" ]]; then
        log "ERROR: model not found at $MODEL"; exit 1
    fi
 
    log "Transcribing with whisper.cpp…"
    "$WHISPER_BIN" -m "$MODEL" -f "$WAV" -nt -np -otxt -of "$WORKDIR/out" >/dev/null 2>&1
 
    TXT="$WORKDIR/out.txt"
    if [[ ! -f "$TXT" ]]; then
        log "ERROR: transcription failed (no output file)."
        echo "STATUS:FAILED"; exit 1
    fi
 
    RESULT="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$TXT" | sed '/^$/d')"
    printf '%s' "$RESULT" | pbcopy
    log "Done. Copied to clipboard: $RESULT"
    rm -f "$WAV" "$TXT"
    echo "STATUS:TRANSCRIBED"; exit 0
fi
 
# ---- START branch: begin a new recording ----------------------------------
pkill -f "$SOX_BIN -q" 2>/dev/null
rm -f "$WAV"
log "Recording… run this script again to stop."
 
# 16 kHz mono 16-bit is what whisper.cpp expects. -d = default input device.
# Redirect sox's output so it does NOT hold open the pipe Hammerspoon watches.
"$SOX_BIN" -q -d -r 16000 -c 1 -b 16 "$WAV" >>"$LOGFILE" 2>&1 &
SOX_PID=$!
echo "$SOX_PID" > "$LOCK"
 
sleep 0.3
if ! kill -0 "$SOX_PID" 2>/dev/null; then
    log "ERROR: sox failed to start. Mic permission for the launching app?"
    rm -f "$LOCK"; echo "STATUS:NOAUDIO"; exit 1
fi
 
log "Recording started (pid $SOX_PID)."
echo "STATUS:STARTED"; exit 0

Make it executable:

chmod +x ~/bin/record.sh

Test it from the terminal (this also triggers the macOS mic-permission prompt — allow it):

bash ~/bin/record.sh     # speak: "call the async function with a callback"
bash ~/bin/record.sh     # run again to stop + transcribe
pbpaste; echo            # should print your sentence

⚠️ Gotcha #2 (the big one): When you background sox and it inherits the script's stdout pipe, the pipe stays open and any tool watching the process (Hammerspoon, below) never sees the script finish — the hotkey appears dead / stuck. Fix: redirect sox's output (>>"$LOGFILE" 2>&1), as above.

⚠️ Gotcha #3: Tools launched by background apps get a minimal PATH. Use absolute binary paths and export a sane PATH, or sox / whisper-cli won't be found.

2.4 The global hotkey (Hammerspoon)

Hammerspoon gives us a scriptable global hotkey.

brew install --cask hammerspoon

Open Hammerspoon once and grant it Accessibility permission when prompted (System Settings → Privacy & Security → Accessibility). Also grant it Microphone access — since it launches sox, the mic prompt belongs to Hammerspoon.

Create ~/.hammerspoon/init.lua. This binds Left Command + Right Option (held together) as the toggle. A plain hotkey can't tell left/right modifiers apart, so we watch key events by keycode.

-- init.lua — Hammerspoon config for voice dictation.
-- Hold Left Command + Right Option together to toggle recording.
 
local RECORD_SCRIPT = os.getenv("HOME") .. "/bin/record.sh"
local LOCK_FILE = "/tmp/whisper_dictation/record.pid"
local busy = false
 
local function pasteFromClipboard()
    hs.eventtap.keyStroke({ "cmd" }, "v")
end
 
local function isRecording()
    local f = io.open(LOCK_FILE, "r")
    if f then f:close() return true end
    return false
end
 
-- The SCRIPT (via its lock file) is the single source of truth. We show an
-- immediate banner, then update it from the script's reported STATUS line.
local function toggleDictation()
    if busy then return end
    busy = true
    hs.alert.closeAll()
    hs.alert.show(isRecording() and "Transcribing…" or "Recording… (chord again to stop)", 10)
 
    hs.task.new("/bin/bash", function(_exitCode, stdout, _stderr)
        busy = false
        hs.alert.closeAll()
        if stdout:find("STATUS:STARTED") then
            hs.alert.show("Recording… (chord again to stop)", 10)
        elseif stdout:find("STATUS:TRANSCRIBED") then
            hs.timer.doAfter(0.15, function()
                pasteFromClipboard()
                hs.alert.show("Done", 1)
            end)
        elseif stdout:find("STATUS:NOAUDIO") then
            hs.alert.show("No audio captured", 2)
        else
            hs.alert.show("Transcription failed", 2)
        end
    end, { RECORD_SCRIPT }):start()
end
 
-- Left Command = keycode 55, Right Option = keycode 61.
local LEFT_CMD_KEYCODE, RIGHT_OPTION_KEYCODE = 55, 61
local leftCmdDown, rightOptDown, chordActive = false, false, false
 
local function checkChord()
    if leftCmdDown and rightOptDown then
        if not chordActive then chordActive = true; toggleDictation() end
    else
        chordActive = false
    end
end
 
dictationWatcher = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e)
    local kc, flags = e:getKeyCode(), e:getFlags()
    if kc == LEFT_CMD_KEYCODE then
        leftCmdDown = flags.cmd == true; checkChord()
    elseif kc == RIGHT_OPTION_KEYCODE then
        rightOptDown = flags.alt == true; checkChord()
    end
    return false
end)
dictationWatcher:start()
 
hs.alert.show("Dictation config loaded (Left Cmd + Right Option)", 1)

Click the Hammerspoon menu-bar icon → Reload Config.

2.5 Use it

  1. Click into any text field.
  2. Hold Left ⌘ + Right ⌥"Recording…".
  3. Speak.
  4. Hold Left ⌘ + Right ⌥ again → "Transcribing…""Done", and the text pastes at your cursor.

Prefer a different trigger? Popular alternatives: a double-tap of Right Option, F13, or Ctrl+Option+Cmd+D. Adjust the watcher accordingly.

💡 Mic gotcha worth knowing: if you use an external USB mic (I use a HyperX QuadCast S) and get "Thank you" or garbage transcriptions, the capture is probably near-silent. The macOS input-volume slider is per-device — raise System Settings → Sound → Input → [your mic] and any hardware gain dial. Whisper's pipeline does no automatic gain, so quiet input becomes hallucinated text.

Part 3 — Local AI Rewrite with Ollama

The flow: select text → press a hotkey (or right-click Quick Action) → a local LLM rewrites it → the result lands on your clipboard → ⌘V to paste.

3.1 Install Ollama + a model

brew install ollama
brew services start ollama        # keeps it running across reboots
ollama pull qwen2.5:14b           # ~9 GB; great for rewriting emails & technical text

qwen2.5:14b is a strong, fast instruction-follower. On a 48 GB machine you can go bigger (qwen2.5:32b); for max speed, llama3.1:8b.

3.2 The rewrite script

Create ~/bin/rewrite.sh. It takes a style argument, reads the selected text from stdin (or the clipboard), asks the local model to rewrite it, and copies the result to the clipboard.

#!/bin/bash
#
# rewrite.sh — rewrite selected text in a given style using a LOCAL model (Ollama).
#   Usage:  echo "text" | rewrite.sh <style>
#   <style>: professional | longer | shorter | casual | prompt
 
set -uo pipefail
 
LOGFILE="/tmp/rewrite.log"
exec 2>>"$LOGFILE"
echo "=== $(date '+%F %T') invoked style=${1:-} ===" >>"$LOGFILE"
 
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
 
MODEL="${REWRITE_MODEL:-qwen2.5:14b}"
OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434/api/chat}"
STYLE="${1:-professional}"
 
INPUT="$(cat)"
[[ -z "${INPUT//[[:space:]]/}" ]] && INPUT="$(pbpaste)"
if [[ -z "${INPUT//[[:space:]]/}" ]]; then
    echo "ERROR: no input text." >&2; exit 1
fi
 
case "$STYLE" in
    professional) INSTR="Rewrite the following text in a more professional tone." ;;
    longer)       INSTR="Expand the following text with more detail and explanation, keeping the same meaning and tone." ;;
    shorter)      INSTR="Condense the following text to its essential meaning, as briefly as possible." ;;
    casual)       INSTR="Rewrite the following text in a relaxed, casual, conversational tone." ;;
    prompt)       INSTR="You are a professional prompt engineer. Rewrite the following into a clear, well-structured prompt for an AI assistant: make the instructions precise and unambiguous, specify the desired output and format, and add any obviously helpful context or constraints, while preserving the original intent. Return only the improved prompt." ;;
    *)            INSTR="Rewrite the following text." ;;
esac
 
RESULT="$(OLLAMA_URL="$OLLAMA_URL" REWRITE_MODEL="$MODEL" INSTR="$INSTR" INPUT="$INPUT" python3 <<'PY'
import os, sys, json, urllib.request, urllib.error
url, model = os.environ["OLLAMA_URL"], os.environ["REWRITE_MODEL"]
instr, text = os.environ["INSTR"], os.environ["INPUT"]
system = ("You are a careful text editor. You rewrite the user's text as instructed and "
          "return ONLY the rewritten text — no preamble, no quotes, no explanation.")
body = json.dumps({
    "model": model, "stream": False, "options": {"temperature": 0.4},
    "messages": [{"role": "system", "content": system},
                 {"role": "user", "content": f"{instr}\n\nText:\n{text}"}],
}).encode("utf-8")
req = urllib.request.Request(url, data=body, headers={"content-type": "application/json"})
try:
    with urllib.request.urlopen(req, timeout=120) as resp:
        sys.stdout.write(json.load(resp)["message"]["content"].strip())
except urllib.error.URLError as e:
    sys.stderr.write("Could not reach Ollama (%s). Is it running?\n" % e); sys.exit(1)
except Exception as e:
    sys.stderr.write("Request failed: %s\n" % e); sys.exit(1)
PY
)"
 
[[ -z "$RESULT" ]] && { echo "ERROR: empty response." >&2; exit 1; }
printf '%s' "$RESULT" | pbcopy
printf '%s' "$RESULT"

Make it executable and test:

chmod +x ~/bin/rewrite.sh
echo "hey can u send me that file whenever ur free thx" | ~/bin/rewrite.sh professional
echo "write a function that sorts users by signup date" | ~/bin/rewrite.sh prompt

3.3 Wrap it in right-click Quick Actions (Shortcuts app)

  1. Open Shortcuts+ to create a shortcut. Name it Rewrite – Professional.
  2. In the top Receive block, set it to receive Text from Quick Actions.
  3. In the ⓘ Details panel: check Use as Quick Action, Finder, and Services Menu.
  4. Add a Run Shell Script action:
    • Shell: zsh, Pass Input: to stdin, Run as Administrator: OFF
    • Script:
      /Users/YOURNAME/bin/rewrite.sh professional
  5. Add a Show Notification action: Rewrite ready — press ⌘V.
  6. Save.

Duplicate for the other styles: right-click the shortcut → Duplicate, rename, and change the single word in the Run Shell Script line to shorter, longer, casual, or prompt.

⚠️ Gotcha #4: Shortcuts' Run Shell Script runs in a sandbox where $HOME is a container path, not your real home. Always use the absolute path to the script.

⚠️ Gotcha #5: macOS TCC protects ~/Desktop, ~/Documents, ~/Downloads. A script inside those folders fails with "Operation not permitted" when launched by Shortcuts. Keep the script in a non-protected folder like ~/bin.

⚠️ Gotcha #6: If you use AppleScript to auto-paste (keystroke "v"), Shortcuts needs Accessibility permission. The simpler, permission-free approach used here is: copy to clipboard + notification, then you press ⌘V.

3.4 Assign hotkeys

In each shortcut's ⓘ Details → Add Keyboard Shortcut, pick a combo. A memorable set:

Action Suggested hotkey
Rewrite – Professional ⌥⌘;
Rewrite – Prompt ⌥⌘P
Rewrite – Shorter ⌥⌘S
Rewrite – Longer ⌥⌘L
Rewrite – Casual ⌥⌘C

Hotkeys work everywhere, including apps like VS Code that draw their own right-click menu and never show macOS Quick Actions.

3.5 Use it

Select text → press the hotkey → "Rewrite ready — press ⌘V" → hit ⌘V. Done.

Daily usage cheat-sheet

Feature How to run
Read Aloud Select text → right-click → Speech → Start Speaking
Dictation Left ⌘ + Right ⌥ → speak → Left ⌘ + Right ⌥ → auto-pastes
AI Rewrite Select text → ⌥⌘P (or your hotkey) → ⌘V to paste

Keep Ollama running with brew services start ollama; Hammerspoon and Ollama both auto-start at login.

Troubleshooting quick reference

  • Dictation hotkey does nothing / stuck "Recording": almost always the sox stdout pipe (Gotcha #2) or a minimal PATH (Gotcha #3). Watch tail -f /tmp/whisper_dictation/record.log.
  • "whisper-cli: command not found": the binary is whisper-cli, not whisper-cpp (Gotcha #1).
  • "No audio captured": grant Microphone permission to Hammerspoon.
  • Transcribes "Thank you" / garbage: input is near-silent — raise the per-device input volume in Sound → Input.
  • Rewrite "Operation not permitted": the script is in a TCC-protected folder — move it to ~/bin (Gotcha #5).
  • Rewrite "Run Shell Script finished with an error" and no /tmp/rewrite.log: $HOME didn't resolve — use the absolute path (Gotcha #4).
  • "not allowed to send keystrokes": grant Accessibility to Shortcuts, or drop the auto-paste and use ⌘V (Gotcha #6).
  • Rewrite hangs: Ollama isn't running — brew services start ollama, verify with curl -s http://localhost:11434/api/tags.

Why local?

Everything here is free after setup, works offline, and never sends your text or voice to a third party. Whisper handles code and technical terms surprisingly well, and a 14B local LLM is more than capable of tightening up emails, messages, and prompts. It's your machine doing the work — private by default.

Happy dictating.

Ready to Optimize Your Store?

Let’s discuss how we can implement these strategies for your business.