Build offline Whisper dictation on a Mac with a hotkey
Whisper dictation on a Mac, built by hand: record with ffmpeg on a hotkey, transcribe with whisper-cli, paste at the cursor. Hammerspoon, Shortcuts or Raycast.
You can build a working offline dictation tool on a Mac with four free pieces: ffmpeg to record the microphone, whisper.cpp to turn the clip into text, pbcopy and an AppleScript keystroke to put the text at the cursor, and a hotkey manager to trigger it all. Press the key, speak, press it again, and the words appear in whatever app is in front. This guide gives you the complete script, the exact commands, three ways to bind a hotkey, and an honest list of what this approach cannot do. Nothing leaves the Mac; the only download is the model file.
If you have not run whisper.cpp before, run Whisper locally on a Mac covers install, models and errors in more depth. This page assumes the basics and concentrates on the dictation loop.
The shape of a push-to-talk dictation tool
Every hotkey dictation script, however it is dressed up, does the same four things:
- Start recording when the hotkey is pressed, writing 16 kHz mono audio to a temporary WAV file.
- Stop recording when the key is pressed again (toggle) or released (hold-to-talk).
- Transcribe the WAV with a local speech model and collect the text.
- Insert the text at the cursor, usually by putting it on the clipboard and sending Command-V to the front app.
Steps 1 and 2 are the only parts that need a global hotkey. Steps 3 and 4 are a plain shell pipeline.
Step 1: install the tools
brew install whisper-cpp ffmpeg
Homebrew's whisper-cpp formula installs the whisper-cli binary (older versions used other names; ls "$(brew --prefix)/bin" | grep -i whisper tells you what you have). ffmpeg does the recording and, on macOS, talks to the microphone through its avfoundation input device.
If you prefer SoX for recording, brew install sox gives you a rec command that also records from CoreAudio. Both are shown below; you only need one.
For the hotkey, the most flexible choice is Hammerspoon:
brew install --cask hammerspoon
Shortcuts (built into macOS) and Raycast are covered as alternatives in Step 6.
Step 2: pick a model
Dictation clips are short, so model load time matters as much as transcription speed. The whisper.cpp project publishes ggml model files on Hugging Face; as of writing the sizes are:
| Model file | Download | Notes |
|---|---|---|
ggml-tiny.en.bin |
78 MB | Fastest, rough on names and jargon |
ggml-base.en.bin |
148 MB | Good default for short English dictation |
ggml-small.en.bin |
488 MB | Noticeably better, still quick on M-series |
ggml-large-v3-turbo-q5_0.bin |
574 MB | Near-large accuracy, quantized, multilingual |
ggml-large-v3-turbo.bin |
1.62 GB | Same model unquantized |
The .en files are English-only. For any other language use a model without the suffix, and see the language section below.
mkdir -p ~/whisper-models
curl -L -o ~/whisper-models/ggml-base.en.bin \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
Step 3: record from the microphone
First find your microphone's index. ffmpeg's avfoundation device lists both video and audio devices; the audio list is what you want:
ffmpeg -f avfoundation -list_devices true -i ""
The output ends with an "Error opening input" line, which is normal for a listing run. The audio devices appear as [0] MacBook Pro Microphone and so on. In the input string "[video]:[audio]", leaving the video half empty records audio only, so ":0" means audio device 0. Record five seconds at 16 kHz mono 16-bit, which is exactly what whisper.cpp expects:
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -c:a pcm_s16le -t 5 clip.wav
The first time, macOS asks whether Terminal may use the microphone. Allow it, and note that when the script is later launched by Hammerspoon or Shortcuts, that app gets its own microphone prompt.
The SoX equivalent uses the same rate and channel flags:
rec -r 16000 -c 1 -b 16 clip.wav
rec records until you press Control-C.
Two details matter for the script. ffmpeg writes the WAV header when it exits, so the recording must be stopped with a signal it handles cleanly (SIGTERM or SIGINT), never kill -9, or the file will have a wrong length in its header. And a background process started from a non-interactive shell ignores SIGINT, so the script uses SIGTERM.
Step 4: transcribe the clip
whisper-cli -m ~/whisper-models/ggml-base.en.bin -f clip.wav -nt -np
-nt (--no-timestamps) prints bare text instead of timestamped segments, and -np (--no-prints) suppresses the system information whisper.cpp normally prints around the result. Together they make stdout safe to capture. Other flags you may want:
-l desets the language;-l autodetects it. The default is English.--prompt "Names, product terms"nudges spelling toward the words in the prompt.-t 8sets threads; the default is usually fine.-sns(--suppress-nst) suppresses non-speech tokens such as bracketed sound descriptions, useful for dictation.
Run whisper-cli --help for the full list; flags occasionally change between versions.
Step 5: put the text at the cursor
The portable way to insert text into any app is the clipboard plus a synthesized Command-V:
printf '%s' "$TEXT" | pbcopy
osascript -e 'tell application "System Events" to keystroke "v" using command down'
The first time this runs, macOS asks two questions. One is an Automation prompt ("Terminal wants access to control System Events"). The other is Accessibility: sending keystrokes requires the calling app (Terminal, Hammerspoon, Shortcuts, Raycast, whichever launched the script) to be enabled in System Settings, Privacy & Security, Accessibility. If it is not, osascript fails with System Events got an error: osascript is not allowed to send keystrokes. (1002). Add the app in that list and run again.
If you would rather not grant Accessibility, drop the osascript line. The text is still on the clipboard and you paste it yourself. That is one keystroke more per dictation, but it needs no permissions beyond the microphone.
The complete script
Save this as ~/bin/dictate.sh and make it executable with chmod +x ~/bin/dictate.sh. The first run starts recording; the second run stops it, transcribes, and pastes. Edit the three variables at the top.
#!/bin/bash
# dictate.sh: toggle offline dictation with ffmpeg + whisper.cpp
set -u
MODEL="$HOME/whisper-models/ggml-base.en.bin"
LANG_CODE="en" # ISO code, or "auto" with a multilingual model
MIC=":0" # audio device index from: ffmpeg -f avfoundation -list_devices true -i ""
DIR="$HOME/.dictate"
WAV="$DIR/clip.wav"
PIDFILE="$DIR/ffmpeg.pid"
mkdir -p "$DIR"
if [ -f "$PIDFILE" ]; then
# Second press: stop recording cleanly so ffmpeg finalizes the WAV header
PID=$(cat "$PIDFILE")
rm -f "$PIDFILE"
kill -TERM "$PID" 2>/dev/null
while kill -0 "$PID" 2>/dev/null; do sleep 0.1; done
TEXT=$(whisper-cli -m "$MODEL" -f "$WAV" -l "$LANG_CODE" -nt -np 2>/dev/null \
| tr -s '\n' ' ' | sed 's/^ *//; s/ *$//')
[ -n "$TEXT" ] || exit 0
printf '%s' "$TEXT" | pbcopy
osascript -e 'tell application "System Events" to keystroke "v" using command down'
else
# First press: start recording at 16 kHz mono, 16-bit PCM
rm -f "$WAV"
ffmpeg -hide_banner -loglevel error -y \
-f avfoundation -i "$MIC" -ar 16000 -ac 1 -c:a pcm_s16le "$WAV" &
echo $! > "$PIDFILE"
fi
Test it from Terminal before binding a key: run it once, say a sentence, run it again, and the sentence should appear in the Terminal window itself. If Homebrew's binaries are not on the PATH of the process that will launch the script (Hammerspoon and Shortcuts do not read your shell profile), replace ffmpeg and whisper-cli with full paths, typically /opt/homebrew/bin/ffmpeg and /opt/homebrew/bin/whisper-cli on Apple Silicon.
Step 6: bind a global hotkey
Hammerspoon
Hammerspoon runs a Lua config at ~/.hammerspoon/init.lua and needs Accessibility access, which it asks for on first launch. hs.hotkey.bind(mods, key, pressedfn, releasedfn, repeatfn) takes separate functions for key down and key up, which gives you both styles for free.
Toggle (press once to start, again to stop):
hs.hotkey.bind({"ctrl", "alt"}, "space", function()
hs.task.new(os.getenv("HOME") .. "/bin/dictate.sh", function() end):start()
end)
Hold-to-talk (record while the key is held): run the same script on press and on release.
local function dictate()
hs.task.new(os.getenv("HOME") .. "/bin/dictate.sh", function() end):start()
end
hs.hotkey.bind({"ctrl", "alt"}, "space", dictate, dictate)
Choose Reload Config from the Hammerspoon menu bar icon after saving. Because Hammerspoon launches the script, it is Hammerspoon that needs the microphone and Accessibility permissions, not Terminal. If you prefer to keep the paste inside Lua, hs.pasteboard.setContents(text) followed by hs.eventtap.keyStroke({"cmd"}, "v") replaces the pbcopy and osascript lines.
Shortcuts
Create a new shortcut with a single "Run Shell Script" action whose body is the full path to dictate.sh. Scripts are off by default: choose Shortcuts, Settings, Advanced and enable "Allow Running Scripts". Then, with the shortcut open, click the details button, choose "Add Keyboard Shortcut", and press a key combination; it appears in the "Run with" field and works in any app. Some combinations are reserved by macOS and cannot be assigned. Shortcuts only offers a toggle, not hold-to-talk.
Raycast
Raycast script commands are shell scripts with a comment header. Save this next to dictate.sh and add the folder under Raycast Settings, Extensions, Add Script Directory:
#!/bin/bash
# @raycast.schemaVersion 1
# @raycast.title Dictate
# @raycast.mode silent
exec "$HOME/bin/dictate.sh"
silent mode runs without showing output. Assign a hotkey by finding the command in Raycast, opening the action panel, choosing Configure Command, then Record Hotkey. Like Shortcuts, this gives a toggle.
Latency expectations
Each hotkey press pays for model load plus transcription; whisper.cpp loads the model fresh every run. Rough figures for a ten-second clip on an M-series Mac, from load to pasted text:
| Model | Typical round trip |
|---|---|
tiny.en |
Under a second |
base.en |
About a second |
small.en |
One to two seconds |
large-v3-turbo (q5_0 or full) |
Several seconds |
Measure your own with time ~/bin/dictate.sh on the stop run. Longer clips scale roughly linearly. A Mac with 8 GB of memory should stay with base.en or small.en.
The language flag
Whisper's default language is English. For German, French, Spanish or any of the other languages the multilingual models cover, set LANG_CODE in the script to the ISO code (de, fr, es) and use a model file without the .en suffix. auto works too, but detection looks at the first few seconds of audio and short dictation clips give it little to go on, so a fixed code is more reliable.
The limits of this approach
It is worth knowing what you are not getting before you rely on this daily.
- No streaming. Text appears only after you stop; nothing is shown while you speak. whisper.cpp does ship a
whisper-streamexample that samples the microphone every half second, but it needs a source build with SDL2 and it re-decodes a sliding window rather than committing text, so it is a demo rather than dictation. - No punctuation control. Whisper adds punctuation from context. You cannot say "comma" or "new paragraph" and have it act as a command; it will type the word.
- Model load on every run. There is no resident process, so the model is read from disk each time. With
base.enthat is quick; with the large models it is the bulk of the wait. - Permissions on the launcher. Microphone, Automation and Accessibility approvals attach to whichever app runs the script, so you will grant them once for Terminal while testing and again for Hammerspoon, Shortcuts or Raycast.
- No rewrite or cleanup. Filler words, false starts and repeated phrases land in the text as spoken. A second pass through a local language model can fix that; summarize transcripts with a local LLM shows the tooling.
- Clipboard side effects. Whatever was on the clipboard is gone after each dictation.
- Hallucinated text on silence. Whisper can invent a sentence over a clip that contains no speech. If you pressed the key by mistake, check the paste before moving on.
Comparing the routes
| Route | Hotkey style | Permissions | Best for |
|---|---|---|---|
| Terminal only | None (run manually) | Microphone | Testing the pipeline |
| Hammerspoon | Toggle or hold-to-talk | Accessibility, microphone | Most control, scriptable |
| Shortcuts | Toggle | Allow Running Scripts, microphone, Accessibility for paste | No extra software |
| Raycast | Toggle | Microphone, Accessibility for paste | Raycast users |
| Apple Dictation | Toggle (built-in) | None | Zero setup, cloud for some languages |
| Dictation app | Toggle or hold, streaming | Microphone, Accessibility | Daily use without maintenance |
macOS's own dictation is worth a look before you build anything; voice typing on a Mac explains what it does on-device and where it falls short.
If you'd rather not maintain this
Frequently asked questions
Can whisper.cpp do real-time dictation on a Mac?
Not in the way a dictation app does. whisper.cpp transcribes a finished audio file, so a script like the one above shows text only after you stop recording. The project's whisper-stream example re-decodes a sliding window of microphone audio every half second, which is a demo rather than dictation. For genuine streaming dictation you need a tool that keeps the model loaded and decodes incrementally.
Which Whisper model is best for dictation?
base.en is the usual starting point for English: it loads in a fraction of a second and handles clear speech well. Step up to small.en if names and technical terms come out wrong. The large models are more accurate still, but the per-run load time makes them feel slow for short sentences. For languages other than English, use a multilingual model without the .en suffix.
Why does osascript say it is not allowed to send keystrokes?
Sending keystrokes through System Events requires Accessibility permission for the app that launched the script, and macOS reports the refusal as error 1002. Open System Settings, Privacy & Security, Accessibility, and enable Terminal, Hammerspoon, Shortcuts or Raycast, whichever ran the script. A separate Automation prompt for controlling System Events appears the first time as well.
Does this dictation script work offline?
After the one-time model download it needs no network at all. Recording, transcription and paste happen on the Mac, and the audio file is written to a folder in your home directory that you can delete or clear at any time. That makes it a reasonable choice for confidential material, with the caveat that the clipboard briefly holds the text.