Build your own push-to-talk dictation on a Mac with a cloud speech API
Cloud dictation on a Mac, built by hand: record with ffmpeg on a hotkey, POST the clip to a transcription API, paste at the cursor. Script, keychain, latency.
You can build hotkey dictation on a Mac against any hosted speech API with four pieces: ffmpeg to record the microphone, curl to upload the clip, jq to pull the text out of the JSON, and pbcopy plus an AppleScript keystroke to place it at the cursor. Press the key, speak, press it again, and the words appear in the front app a second or two later. This guide gives you the complete script, curl calls against two providers' documented endpoints, the right way to keep an API key on a laptop, and an honest account of the latency and the failure modes.
This is the cloud sibling of build your own offline voice typing with whisper.cpp. The recording and paste halves are identical, so read that guide for the ffmpeg, permission and hotkey detail; this page covers only what changes when transcription happens on someone else's machine.
The shape of a cloud dictation script
The loop is the same four steps as the local route:
- Start recording on the hotkey press, writing 16 kHz mono audio to a temporary WAV file.
- Stop recording on the next press, closing the file cleanly.
- Transcribe, and here is the one structural difference: instead of running a model locally, you open a TLS connection, upload the clip, wait for the provider, and read the text out of a JSON response. That round trip sits in the middle of your latency budget, and it is the part you control least.
- Insert the text at the cursor through the clipboard.
Everything else follows from step 3: a safe place for the credential, a timeout, a retry for transient failures, and a rule for your audio when a request fails for good.
Step 1: record the microphone
Identical to the local guide. Find the audio device index first:
ffmpeg -f avfoundation -list_devices true -i ""
The listing run ends with an "Error opening input" line, which is normal. Audio devices appear as [0] MacBook Pro Microphone. In the input string "[video]:[audio]" an empty video half means audio only, so ":0" is audio device 0. Record 16 kHz mono 16-bit PCM, and keep it there: speech APIs downsample anyway, and a 48 kHz stereo clip is six times the bytes to upload for no accuracy gain.
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -c:a pcm_s16le -t 5 clip.wav
Stopping matters more here than on the local route. ffmpeg writes the WAV header when it exits, so the recorder must be stopped with a signal it handles, SIGTERM or SIGINT, never kill -9. A wrongly sized container is the kind of thing a local decoder shrugs off and a strict server-side parser rejects with a 400, after you have paid for the upload. The script below sends SIGTERM and waits for the process to exit before touching the file.
Step 2: send the audio to the API
Two providers, two body shapes, both from their own API references. Export the key in your shell for testing only; the script reads it from the keychain instead.
A multipart form upload, from the OpenAI speech-to-text guide:
curl --request POST \
--url https://api.openai.com/v1/audio/transcriptions \
--header "Authorization: Bearer $OPENAI_API_KEY" \
--form file=@clip.wav \
--form model=gpt-transcribe
file and model are the required fields. The documented file limit is 25 MB, about thirteen minutes at 16 kHz mono 16-bit and far more than a dictation clip needs. gpt-transcribe takes a languages array, while the older whisper-1 takes a singular language with an ISO 639-1 value, so check the reference before pinning one. The default JSON response:
{ "text": "Bonjour, pouvez-vous m'entendre ?", "languages": [{ "code": "fr" }] }
So the text comes out with:
jq -r '.text'
A raw binary upload, from the Deepgram pre-recorded audio guide:
curl --request POST \
--header "Authorization: Token $DEEPGRAM_API_KEY" \
--header "Content-Type: audio/wav" \
--data-binary @clip.wav \
--url 'https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=en'
There is no form here: the audio is the request body, Content-Type declares its format, and the options ride in the query string. smart_format=true applies formatting for readability, worth having for dictation. The response nests the result under channels and alternatives:
jq -r '.results.channels[0].alternatives[0].transcript'
Both companies publish SDKs and their docs lead with them, but for a shell script the plain HTTP form is better: no runtime to install, one obvious place to put a timeout.
Step 3: put the text at the cursor
Same as the local guide, and the same two permission prompts:
printf '%s' "$TEXT" | pbcopy
osascript -e 'tell application "System Events" to keystroke "v" using command down'
Sending keystrokes needs the launching app enabled in System Settings, Privacy & Security, Accessibility, or osascript fails with error 1002. Drop the second line if you would rather paste yourself. The local guide covers both prompts in full.
The complete script
Save as ~/bin/cloud-dictate.sh and chmod +x it. The first run starts recording; the second stops, uploads and pastes. It needs jq (brew install jq).
#!/bin/bash
# cloud-dictate.sh: toggle push-to-talk dictation through a cloud transcription API
set -euo pipefail
PROVIDER="openai" # "openai" or "deepgram"
MIC=":0" # from: ffmpeg -f avfoundation -list_devices true -i ""
KEY_SERVICE="dictate-api-key" # keychain service name
LANGUAGE="en" # used by the Deepgram path below
FFMPEG="/opt/homebrew/bin/ffmpeg"
JQ="/opt/homebrew/bin/jq"
CURL="/usr/bin/curl"
DIR="$HOME/.dictate-cloud"
WAV="$DIR/clip.wav"
PIDFILE="$DIR/ffmpeg.pid"
mkdir -p "$DIR"
notify() { /usr/bin/osascript -e "display notification \"$1\" with title \"Dictation\"" >/dev/null 2>&1 || true; }
keep_and_fail() {
local kept="$DIR/failed-$(date +%Y%m%d-%H%M%S).wav"
if [ -f "$WAV" ]; then mv "$WAV" "$kept"; fi
echo "$1 Audio kept at $kept" >&2
notify "$1 Audio kept at $kept"
exit 1
}
post_openai() {
"$CURL" -sS --fail-with-body --max-time 60 \
--request POST \
--url https://api.openai.com/v1/audio/transcriptions \
--header "Authorization: Bearer $API_KEY" \
--form "file=@$WAV" \
--form "model=gpt-transcribe"
}
post_deepgram() {
"$CURL" -sS --fail-with-body --max-time 60 \
--request POST \
--url "https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=$LANGUAGE" \
--header "Authorization: Token $API_KEY" \
--header "Content-Type: audio/wav" \
--data-binary "@$WAV"
}
send_with_retry() {
local attempt=1 delay=2 body=""
while [ "$attempt" -le 3 ]; do
if body=$("post_$PROVIDER"); then printf '%s' "$body"; return 0; fi
[ "$attempt" -lt 3 ] && sleep "$delay"
delay=$((delay * 2)); attempt=$((attempt + 1))
done
return 1
}
if [ -f "$PIDFILE" ]; then
# Second press: stop the recorder, then upload
PID=$(cat "$PIDFILE"); rm -f "$PIDFILE"
kill -TERM "$PID" 2>/dev/null || true
while kill -0 "$PID" 2>/dev/null; do sleep 0.1; done
[ -s "$WAV" ] || exit 0
API_KEY=$(security find-generic-password -s "$KEY_SERVICE" -a "$USER" -w) \
|| keep_and_fail "No API key in the keychain."
RESPONSE=$(send_with_retry) || keep_and_fail "Transcription request failed after 3 attempts."
case "$PROVIDER" in
openai) TEXT=$(printf '%s' "$RESPONSE" | "$JQ" -r '.text // empty') ;;
deepgram) TEXT=$(printf '%s' "$RESPONSE" | "$JQ" -r '.results.channels[0].alternatives[0].transcript // empty') ;;
esac
[ -n "$TEXT" ] || keep_and_fail "The API returned no text."
printf '%s' "$TEXT" | pbcopy
/usr/bin/osascript -e 'tell application "System Events" to keystroke "v" using command down'
rm -f "$WAV"
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
Four details separate this from a demo. set -euo pipefail stops at the first error instead of pasting whatever came back. --max-time 60 caps the request, so a dead connection costs a minute rather than a hung process holding a stale PID file. send_with_retry tries three times with a doubling delay, covering a dropped connection or a rate-limit rejection without hammering the provider. And keep_and_fail never deletes your audio: a failed dictation leaves a timestamped WAV in ~/.dictate-cloud to retry by hand, which matters when what you said was a two-minute train of thought.
Full paths are used because Hammerspoon, Shortcuts and Raycast do not read your shell profile. Adjust them on Intel Macs, where Homebrew lives in /usr/local.
Binding a global hotkey
All three options from the local guide work unchanged, since the script only needs running twice. Hammerspoon, in ~/.hammerspoon/init.lua:
hs.hotkey.bind({"ctrl", "alt"}, "space", function()
hs.task.new(os.getenv("HOME") .. "/bin/cloud-dictate.sh", function() end):start()
end)
Shortcuts needs a "Run Shell Script" action with "Allow Running Scripts" enabled in Settings, Advanced, then a keyboard shortcut assigned in the details pane. Raycast takes a script command with a @raycast.mode silent header. The local guide has all three in full, including hold-to-talk. Microphone, Automation and Accessibility permissions attach to whichever app launches the script, not to Terminal.
Keeping the API key off disk
A key pasted into the script ends up in a backup, a screen share, or a repository. The macOS keychain is right there and needs no extra software. Add the key once, with -w at the end so the shell prompts instead of putting the secret in your history:
security add-generic-password -a "$USER" -s "dictate-api-key" -w
Read it back at runtime, which is what the script does:
API_KEY=$(security find-generic-password -s "dictate-api-key" -a "$USER" -w)
The first read from a new process shows a keychain prompt where you can allow access always, and security delete-generic-password -s "dictate-api-key" -a "$USER" removes the item.
Two habits are worth keeping. Create a separate key for this script rather than reusing a production one, and scope it to transcription if the provider offers per-key permissions or spend limits, so a leak costs a capped amount. And rotate it: generate a new key, run add-generic-password with -U to update the stored item, then revoke the old one. Check too that ~/.dictate-cloud is not inside a synced folder.
Where the latency goes
The local route pays model load plus decode. This one pays upload, queue, decode and response. Rough figures for a ten-second clip, about 320 KB at 16 kHz mono 16-bit, on a home connection:
| Stage | Estimate | Depends on |
|---|---|---|
| Stop and finalize the WAV | Under 0.1 s | Nothing much |
| Connection and TLS setup | 0.1 to 0.3 s | Distance to the provider |
| Upload of a 10 s clip | 0.2 to 1 s | Your uplink, not your downlink |
| Provider queue and processing | 0.5 to 2 s | Model, load, tier |
| Response, jq, paste | Under 0.1 s | Nothing much |
Treat that as an estimate, not a benchmark: your numbers will differ with distance, uplink and time of day, and time ~/bin/cloud-dictate.sh on the stop run gives you real ones.
The shape is the point. Perhaps a second of the total is fixed overhead that a two-word dictation pays in full, which is why short bursts feel worse than long ones: 1.5 seconds after three seconds of speech is a 50 percent tax, while the same 1.5 seconds after a minute of speech is noise. Uplink matters more than people expect, since home and cafe connections are asymmetric and a minute of audio is nearly 2 MB going the wrong way.
Streaming instead of batch
Every major provider also sells a real-time tier, and it is a different animal. You open a websocket, wss://api.deepgram.com/v1/listen in Deepgram's live audio reference, or a realtime session with a live transcription model in OpenAI's realtime transcription guide, and push audio frames as they are captured. Text comes back while you speak: Deepgram documents interim results with is_final=false that firm up into a final result, and the realtime API emits incremental transcription deltas. That is what makes commercial dictation feel live, and it removes most of the upload wait, since the audio is already there when you stop.
A shell script is the wrong tool for it. Streaming means holding a socket open, framing audio into chunks continuously, handling two kinds of result, reconciling interim text that gets revised after you have shown it, and surviving a reconnect mid-sentence. None of that is expressible in curl, and showing interim text needs something that owns a piece of the screen. The next step up is a small program in Python, Swift or Node against the provider's SDK, which is an app rather than a script.
What this route costs and what breaks
The per-minute meter. Every press bills audio seconds, and dictation is a high-frequency habit, so it accumulates faster than the occasional meeting. Cloud transcription API costs does the arithmetic.
No offline operation, ever. Not degraded, not queued: on a plane, on hotel wifi that is connected but useless, or in a building where you will not join the guest network, this script cannot produce a word. The local route works in all three cases.
Rate limits and outages. Providers return HTTP 429 when you exceed a quota; the retry absorbs the occasional one, and a sustained one means checking your plan. When the provider has a bad hour, so does your dictation, unless you keep a local model installed as well. Running both scripts on two hotkeys is a reasonable answer.
Your audio leaves the machine every time. Each press uploads your voice to a third party, whatever the sentence contains. Retention windows, training defaults and subprocessor lists differ by provider and tier and they change, so read the documentation and terms of the one you pick, and confirm anything that matters for your work with your own advisor. That is a normal product shape, not a scandal, but it deserves a deliberate decision. The key is on your laptop too, protected by the keychain but still one more thing to revoke if the machine goes missing.
Comparing the three routes
| This cloud script | Local script | A finished app | |
|---|---|---|---|
| Setup effort | An hour, plus an account and a key | An hour, plus a model download | Minutes |
| Latency | Around 1 to 3 s, network dependent | Under 1 s to several s, model dependent | Typically lowest, often streaming |
| Offline | No | Yes | Depends on the app |
| Per-use cost | Metered per minute of audio | None after setup | Flat or subscription |
| Privacy | Audio uploaded each time | Nothing leaves the Mac | Depends on the app |
| Who maintains it | You, plus the provider's API changes | You, plus tool updates | The vendor |
Neither script is wrong. If you already have API credit, want a particular provider's accuracy on your accent, or work on hardware that struggles with a local model, the cloud route is sound. If you dictate constantly, travel, or handle sensitive material, the local one fits better.
If you'd rather not maintain this
Frequently asked questions
Can I use my own speech API key with a dictation tool on a Mac?
Some hosted dictation tools let you supply your own provider key, and many do not, so check before buying. The script in this guide is the fallback that always works: you own the key, you pick the provider, and switching means editing two functions.
Is cloud dictation faster than a local model on a Mac?
Usually not for short dictation. A cloud request pays connection setup, upload and queueing before any speech is processed, roughly a second of fixed overhead that a two-second clip cannot amortize, while a small local model on Apple Silicon returns text in well under a second. Cloud services win on long audio, weak hardware and unusual languages, where their larger models make up the difference.
Where should I keep the API key for a dictation script?
In the macOS keychain, added once with security add-generic-password and read at runtime with security find-generic-password -w. That keeps the secret out of the script, out of your shell history and out of any dotfile that might be committed or synced. Use a key created for this purpose only, scope or cap it if the provider allows, and rotate it.
Does a cloud dictation script work offline?
It does not. Every press needs a working connection, so the script fails on a plane, on a dead hotel network and during a provider outage. The failure path above at least keeps the recording on disk to retry later. If offline dictation is what you need, the local whisper.cpp version does the same job with a model file on your own machine.