Hands-free, 100% local Whisper dictation on macOS — triggered from your keyboard, with an optional on-device LLM cleanup pass and a notch-area parrot cage indicator.
The parrot cage hangs from the notch — the bird rests green when ready, turns yellow while warming, and goes red while recording / flying out hands-free.
Hammerspoon intercepts keyboard gestures system-wide and fires shortcuts directly; a small always-on Python daemon keeps a local Whisper model warm in memory and, on release, transcribes into the focused field — no cloud, no API keys. An optional local LLM cleanup pass then polishes the transcript (punctuation, filler removal, bulleting) before it lands. The default parrot cage indicator lives under the MacBook notch: the bird is green when ready, yellow while warming, and red while recording or hands-free.
Everything ships in one Hammerspoon script with the keyboard layer on by default. It's the sibling of supa-coder (same idea for a Bluetooth gamepad); the dictation engine is shared.
Diagram source: assets/keybinds.html
Keyboard (on by default — no extra hardware):
| Gesture | Action |
|---|---|
| Fn / Globe — tap | Hands-free: the bird flies out and recording starts. Tap Fn again to stop & paste |
| Fn / Globe — hold past 3s, then release | Hold mode (push-to-talk): the bird stays out of the cage while you hold; record while held, paste on release |
| Right Shift — double-tap | Region screenshot to clipboard (drag a box, then ⌘V) |
| Right Shift — hold ½ second (alone) | Toggle status view between the parrot cage and original notch outline |
| Fn / Globe + + / - | Resize the cage larger or smaller |
Required once for Fn: System Settings → Keyboard → Function Keys → set "Press Globe key to:" → "Do Nothing", so macOS doesn't pop the emoji / Character Viewer panel when you tap Fn (a Hammerspoon event tap can't suppress it — the popup is fired by the WindowServer below the tap, so this system setting is the only fix). (Prefer another key? Set
FN_KEYCODEat the top of the script — e.g. Right Command is54.)
Screenshots go to the clipboard, so ⌘V drops the grab wherever your cursor is.
The normal UI uses notch-area views only. INDICATOR = "cage" is the default, and
holding Right Shift alone for half a second switches live between the parrot cage
and the original notch outline.
"cage" — a line-art parrot cage hanging from the notch:
The bird uses the same colour scheme as the notch outline — green ready, yellow warming, red in use:
- Booting — the cage swings gently while the daemon is starting; the bird is yellow (warming, not ready yet).
- Idle / armed — the bird rests green in the cage with a subtle motion and random blinks, so you know the system is ready.
- Warming — the bird turns yellow while the mic opens (don't talk yet).
- Recording — the bird turns red once the mic is live.
- Hands-free — the bird stays red, flaps out, and patrols back and forth near the notch until you stop.
- Transcribing / copied — the bird flies back, pulses while Whisper works, then gives a bright flash once text is copied.
- Daemon stopped — the bird falls off the perch (grey) and lies at the bottom of the cage.
"notch" — the original outline around the MacBook notch:
- Armed — steady green outline whenever the daemon is reachable. No outline ⇒ the daemon is down.
- Ready — a green stem grows down from the notch the moment the daemon finishes loading.
- Recording — pulses yellow while the mic warms up, then pulses red once it's live, back to green on stop.
There is no bottom-center popup; recording, transcribing, and copied feedback all stay in the notch area.
Notch geometry is hard-coded for a 14-inch MacBook Pro (
NOTCH_W = 185,NOTCH_H = 32). On a different model, set those at the top of the script. Read your exact size:python3 -c "import AppKit;s=AppKit.NSScreen.screens()[0];i=s.safeAreaInsets();l=s.auxiliaryTopLeftArea();r=s.auxiliaryTopRightArea();print('H',i.top,'W',s.frame().size.width-l.size.width-r.size.width)"
- macOS on Apple Silicon (Hammerspoon, mlx-whisper, mlx-lm, and pyobjc are Mac-only; MLX needs an M-series chip)
- Python 3.11+
- ffmpeg on your
PATH—brew install ffmpeg - Hammerspoon —
brew install --cask hammerspoon
Setting up with a coding agent? Copy the prompt from the Agent setup prompt section below into Claude Code / Cursor / Codex and it'll run the whole setup for you (pausing for the few macOS permission toggles it can't click).
git clone https://github.com/aamonnaig4/parrot.git
cd parrot
./setup.sh # venv + deps, checks ffmpeg, writes config.json, DOWNLOADS the modelssetup.sh also pre-downloads the Whisper model (and the LLM cleanup model, if
format is on) so your first dictation doesn't stall — it detects anything
already cached and skips it.
Add this to ~/.hammerspoon/init.lua (point it at wherever you cloned the repo):
dofile(os.getenv("HOME") .. "/path/to/parrot/hammerspoon/parrot.lua")Then Reload Config from the Hammerspoon menu. The keyboard layer is on by default — don't forget the one-time "Press Globe key to: Do Nothing" setting above.
./start.sh # foreground; shows the [heard]/[typed] log. Ctrl+C to quit.The daemon warms the mic, the Whisper model, and (if enabled) the cleanup model at startup, so the first dictation is as fast as the rest.
Auto-start on boot: the script sets Hammerspoon to launch at login
(AUTOLAUNCH_AT_LOGIN), and Hammerspoon in turn starts the daemon in Terminal
automatically if it isn't already running — Terminal carries the microphone
permission a plain background agent would lack. So a fresh boot warms everything
with nothing to click. Set AUTOSTART_DAEMON = false / AUTOLAUNCH_AT_LOGIN = false
to manage it yourself.
Want a coding agent (Claude Code, Cursor, Codex, …) running on your Mac to do the setup for you? Copy the entire prompt below and paste it into the agent. It runs the automatable steps and pauses for the 🧑 HUMAN steps (System Settings toggles it can't click).
Click to expand the copy-paste prompt
# parrot — setup guide for coding agents
## Agent: read this first
You are setting up **parrot**, a 100% local Whisper dictation tool for macOS. It has two
halves that must both be running:
1. **A Python daemon** (`parrot/daemon.py`) that keeps a local Whisper model warm and listens
on a loopback control socket (`127.0.0.1:8765`). Started with `./start.sh`.
2. **A Hammerspoon Lua script** (`hammerspoon/parrot.lua`) that watches the Fn/Globe key,
tells the daemon to start/stop recording, and draws the notch "parrot cage" indicator.
Loaded by adding a `dofile(...)` line to `~/.hammerspoon/init.lua`.
**Hard requirements** (verify before starting; stop and tell the user if unmet):
- macOS on **Apple Silicon** (M-series). MLX will not run on Intel.
- **Python 3.11+**, **Homebrew**, and internet access (first run downloads the Whisper model).
**What you CANNOT do** (these are GUI toggles — pause and hand them to the user):
- Grant Accessibility / Input Monitoring / Microphone permissions.
- Change the "Press 🌐 key to" keyboard setting.
Do NOT claim setup is complete until the user confirms the 🧑 HUMAN steps are done and a
test dictation actually types text.
Work through the steps in order. After each command, check its output before moving on.
---
## Step 1 — Prerequisites
```bash
# Homebrew packages (safe to re-run; skips what's already installed)
brew install ffmpeg
brew install --cask hammerspoon
# Verify
sw_vers # macOS version
uname -m # must print: arm64
python3 --version # must be 3.11+
command -v ffmpeg # must print a path
ls -d /Applications/Hammerspoon.app # Hammerspoon installed
```
If `uname -m` is not `arm64`, stop — this tool cannot run on this machine.
## Step 2 — Clone and install
```bash
# Clone wherever the user keeps projects (adjust the path if they already cloned it)
cd ~/Developer 2>/dev/null || cd ~
git clone https://github.com/aamonnaig4/parrot.git 2>/dev/null || true
cd parrot
# One-time setup: creates .venv, installs deps, checks ffmpeg, writes parrot/config.json,
# and DOWNLOADS the Whisper model (this can take a few minutes on first run).
./setup.sh
```
Record the absolute repo path — you need it in Step 3:
```bash
REPO="$(pwd)"; echo "$REPO"
```
## Step 3 — Wire it into Hammerspoon
Add a single `dofile` line to `~/.hammerspoon/init.lua`, pointing at this repo. Do it
idempotently (don't add a duplicate if it's already there):
```bash
mkdir -p ~/.hammerspoon
LINE="dofile(\"$REPO/hammerspoon/parrot.lua\")"
grep -qF "hammerspoon/parrot.lua" ~/.hammerspoon/init.lua 2>/dev/null \
|| echo "$LINE" >> ~/.hammerspoon/init.lua
tail -n 3 ~/.hammerspoon/init.lua
```
Then tell the user to open Hammerspoon and **Reload Config** from its menu-bar icon (the
first launch will also prompt for Accessibility — see Step 4).
## Step 4 — 🧑 HUMAN: grant permissions
Ask the user to grant these in **System Settings → Privacy & Security**. The agent cannot
toggle these; pause here until the user confirms.
- **Accessibility** — enable **Hammerspoon** AND the terminal app they'll run `./start.sh`
from (Terminal / iTerm / their IDE).
- **Input Monitoring** — enable the same two apps.
- **Microphone** — enable the terminal app (macOS will also prompt on the first recording).
Without these you'll see `This process is not trusted!` or no text will appear.
(Screen Recording is **not** required for normal use.)
## Step 5 — 🧑 HUMAN: stop the Fn key from opening the emoji picker
parrot uses a lone Fn/Globe tap to control dictation. By default macOS pops the emoji /
Character Viewer on that tap, and a Hammerspoon event tap **cannot** suppress it (the popup
is fired by the WindowServer below the tap). The only fix is the system setting:
**System Settings → Keyboard → Function Keys → "Press 🌐 key to:" → "Do Nothing".**
The agent may set the underlying preference, but it only takes effect after a **logout/login**:
```bash
defaults write com.apple.HIToolbox AppleFnUsageType -int 0 # 0 = Do Nothing
```
Tell the user to either flip the toggle in System Settings (takes effect immediately) or log
out and back in after the command above.
## Step 6 — Start the daemon and reload Hammerspoon
```bash
# Start the daemon in the foreground (shows the [heard]/[typed] log). Leave it running.
# NOTE: run this in a terminal that has Microphone + Accessibility permission (Step 4).
cd "$REPO" && ./start.sh
```
On startup it prints a `mic check OK — '<name>' peak 0.NN` line (good) or a
`⚠️ mic … is SILENT` warning (no mic permission / wrong device — revisit Step 4).
If Hammerspoon's `hs` CLI is available you can reload from the agent; otherwise ask the user
to **Reload Config** from the Hammerspoon menu:
```bash
command -v hs >/dev/null && hs -c "hs.reload()" 2>/dev/null || echo "Reload Hammerspoon manually"
```
> parrot also auto-starts on boot: the script sets Hammerspoon to launch at login, and
> Hammerspoon launches the daemon in Terminal automatically if it isn't already up. So after
> this first setup, a reboot warms everything with nothing to click.
## Step 7 — Verify (do not declare done before this passes)
Ask the user to:
1. Click into any text field (Notes, a browser box, etc.).
2. **Tap Fn/Globe, say a sentence, tap Fn again.** Text should appear (pastes on stop).
3. Look at the **MacBook notch**: a small parrot cage hangs from it — the bird uses the same
green/yellow/red scheme as the notch outline, so it sits **green** when ready. A single
**tap of Fn** starts **hands-free** recording (the bird flies out **red**); tap Fn again to
stop and paste. Holding Fn past 3s instead switches to **hold mode** — the bird stays **red**
and **out of its cage** the whole time you hold (so you can see the hold is working); it
becomes push-to-talk (record while held, release to paste).
4. **Hold Right Shift alone for half a second** — the indicator should toggle between the
parrot cage and the original notch outline.
If nothing types: confirm the daemon terminal shows `[heard]`/`[typed]` activity, re-check
Step 4 permissions, and confirm Step 5 is applied.
## Step 8 — Optional configuration
Edit `parrot/config.json` (created by `setup.sh`, gitignored):
| Field | Default | What it does |
|-------|---------|--------------|
| `model` | `small` | `tiny·base·small·medium·large` (bigger = more accurate, slower/heavier) |
| `mic` | `""` | Substring to force a mic (e.g. `"MacBook Pro Microphone"`); empty = system default |
| `stream` | `false` | `false` = batch (paste on stop); `true` = type words live as you talk |
| `format` | `false` | `true` = local LLM cleanup pass (punctuation/filler/bulleting) after transcription |
| `format_model` | `""` | Optional cleanup model repo id; empty = a small default (Qwen2.5-3B-Instruct-4bit) |
After changing `model` or `format`, restart the daemon (`Ctrl+C`, then `./start.sh`) so it
reloads. Indicator/keybinding defaults (`INDICATOR`, `WAVE_ON_NOTCH`, key codes, timings)
live at the top of `hammerspoon/parrot.lua`; **Reload Config** after editing.
---
## Troubleshooting quick reference
- **Emoji picker still pops on the Fn tap** — Step 5 not applied, or applied but not yet
logged out/in.
- **`This process is not trusted!` / no text appears** — Accessibility + Input Monitoring +
Microphone not granted to both Hammerspoon and the daemon's terminal (Step 4).
- **"dictation daemon not running" alert** — the daemon isn't up; run `./start.sh`.
- **`⚠️ mic … is SILENT`** — no mic permission or wrong input device; fix Step 4 or set `mic`.
- **Verbose daemon log** — `PARROT_DEBUG=1 ./start.sh`.
- **Cage/notch indicator wrong size or model** — notch geometry is hard-coded for a 14" MacBook
Pro (`NOTCH_W`, `NOTCH_H` at the top of `hammerspoon/parrot.lua`); adjust for other models.
Full human-facing docs are in the repo's `README.md`.In System Settings → Privacy & Security, grant both apps under Accessibility and Input Monitoring:
- Hammerspoon — to watch keyboard gestures and post keystrokes.
- The terminal app you run
./start.shfrom (Terminal, iTerm, your IDE) — the daemon pastes transcribed text via ⌘V.
The terminal also needs Microphone access (you'll be prompted on first
record). Without these you'll see This process is not trusted! or no text
appears.
setup.sh copies parrot/config.example.json
to parrot/config.json (gitignored, so it's yours to edit):
| Field | What it does |
|---|---|
model |
tiny · base · small · medium · large (larger = more accurate, slower, bigger download) — or a full mlx-community/…-mlx repo id. |
mic |
Substring to force a specific mic, e.g. "MacBook Pro Microphone" or "DJI". Empty = follow the macOS system default input (Settings → Sound → Input). |
stream |
false (default) = batch: transcribe the whole utterance on stop and paste it all at once. true = type words live as you talk. |
format |
true = run the local LLM cleanup pass after transcription (punctuation, filler removal, bulleting). Batch mode only. |
format_model |
Optional mlx-community/…-Instruct-*bit repo id for the cleanup pass. Empty = a small default (Qwen2.5-3B-Instruct-4bit, ~1.6 GB, ~1 s/utterance). |
Enabled/timed behavior — ENABLE_KEYBOARD, key codes, timings, and indicator defaults —
lives at the top of hammerspoon/parrot.lua.
INDICATOR = "cage"(default) — the parrot cage hangs from the notch. Set it to"notch"for the original notch border. Hold Right Shift alone for half a second to toggle those two live, and Fn + + / - makes the cage larger or smaller.WAVE_ON_NOTCH = false(default) — whentrue, live recording shows audio-driven ripple rings on the notch outline (only applies withINDICATOR = "notch").- To smoke-test the cage animation after reload, create
/tmp/parrot-smoke-testbefore starting Hammerspoon. The next load runs a one-shot green → yellow → red → grey animation sequence and then returns to idle.
Dictation runs mlx-whisper
— OpenAI's Whisper ported to Apple's MLX, so
it runs on the Apple-Silicon GPU, fully offline (no cloud, no API keys). Models
are pulled from mlx-community on first use
and cached under ~/.cache/huggingface. Set the size via model in
config.json (or the WHISPER_MODEL env var, which wins); it's locked to
English.
model |
Hugging Face repo | ~Download | Notes |
|---|---|---|---|
tiny |
whisper-tiny-mlx |
~75 MB | Fastest, least accurate |
base |
whisper-base-mlx |
~145 MB | Fast, okay accuracy |
small |
whisper-small-mlx |
~480 MB | Recommended — best accuracy/speed balance for dictation on an M-series Mac |
medium |
whisper-medium-mlx |
~1.5 GB | More accurate, slower/heavier |
large |
whisper-large-v3-mlx |
~3 GB | Most accurate, slowest |
Recommended: small (the default) — on an M-series MacBook it transcribes
conversational speech accurately and keeps up with live dictation. Step up to
medium if you want a bit more accuracy and have the headroom; drop to base/tiny
on older hardware or if you want it snappier. Change it in config.json, then
restart the daemon (Ctrl+C, ./start.sh) so it reloads the model.
Batch (default): you talk, and on release the whole utterance is transcribed once and pasted in one go — calmer than live typing, with no mid-sentence revisions. Auto-gain and hallucination guards keep it clean; spoken punctuation works ("comma", "period", "new line", …).
Cleanup pass ("format": true): the raw transcript is then run through a small
resident MLX instruct model that fixes punctuation/capitalization, drops filler
words and false starts, resolves self-corrections, and bullets clear lists —
Wispr-style — without changing your wording. It's strictly guided by a few-shot
prompt and falls back to the raw text on any error, so it can never lose or
block a dictation. Runs ~1 s on the default 3B model.
Live streaming ("stream": true): the older mode — types words it's confident
about a beat behind your voice, append-only (LocalAgreement-2). Formatting is
skipped in this mode (it needs the whole utterance).
Details in parrot/whisper_dictate.py and
parrot/reformat.py.
.venv/bin/pip install pytest
PYTHONPATH=parrot .venv/bin/python -m pytest -qCovers the word-commit engine, voice commands, flush behavior, and the daemon's command dispatch.
- Fn does nothing — set System Settings → Keyboard → "Press Globe key to: Do Nothing", then Reload Config. If it still feels laggy, switch
FN_KEYCODEto Right Command (54). This process is not trusted!— grant Accessibility + Input Monitoring to the terminal running the daemon (and to Hammerspoon).- "dictation daemon not running" alert — start it with
./start.sh. - No text appears — check the terminal's Microphone permission. On startup the daemon prints
mic check OK — '<name>' peak 0.NNor amic … is SILENTwarning; the latter means no mic permission or the wrong device. Watch the[heard]/[typed]log to see whether audio is reaching the model. - Verbose daemon log —
PARROT_DEBUG=1 ./start.shlogs every control command and reply.
Hammerspoon (Lua) Python daemon
hs.eventtap on Fn / Right-Shift mlx-whisper model kept warm
mlx-lm cleanup model kept warm (optional)
├─ posts ⌘V / screenshots / Enter dictation engine + LLM cleanup pass
├─ draws the status indicator ─────► socket server on 127.0.0.1:8765
│ (parrot cage or notch outline)
└─ sends start/stop for dictation TCP start | stop | toggle | ping | quit
▲ reads /tmp/parrot-dictation.status ── daemon writes transcribing / done:<chars>
Dictation is 100% local — audio never leaves your Mac and there are no API
keys. The daemon's control socket binds loopback only (127.0.0.1) and is
unauthenticated: any process running as your user on this machine could send
it start/stop and thus toggle mic recording. That's an accepted trade-off for
a personal tool; don't run the daemon on a shared/multi-user machine, and note
that parrot.lua runs with Accessibility + Input Monitoring, so only load
config you've read.