A minimal, local-only macOS menu-bar dictation app powered by mlx-whisper on Apple Silicon. Press a global hotkey, speak, release β and your speech is transcribed and dropped onto your clipboard (or auto-typed into the focused app).
No cloud. No telemetry. No Dock icon. Just a tray icon and a hotkey.
π Website: whisper-app-ashy.vercel.app
Status: v0 β works on the developer's machine. Apple Silicon (M-series) only. Not yet code-signed.
- ποΈ Hold-to-talk hotkey. Default
β₯β(hold both, speak, release). - π§ On-device transcription. Uses
mlx-whisperagainst models cached under~/.cache/huggingface/hub/. - π Output modes. Clipboard, auto-type via AppleScript
System Events, or both. - ποΈ Tray menu. Pick a model (
whisper-turbo,whisper-large-v3-turbo, β¦) and an output mode. Settings persist to~/Library/Application Support/whisper-app/config.json. - π’ Status icon. Idle β recording β processing β done, with a clear error state.
- π« No Dock icon, no window. Pure menu-bar app (
LSUIElement = trueequivalent).
| Hardware | Apple Silicon (M-series) β mlx-whisper runs on the Neural Engine / GPU |
| macOS | Sequoia (26.x) or later |
| Python | python3 on PATH with mlx_whisper importable |
| ffmpeg | Required at /opt/homebrew/bin/ffmpeg (the Homebrew default on Apple Silicon β this path is currently hardcoded) |
| Models | At least one model pre-downloaded to ~/.cache/huggingface/hub/ |
| Permissions | Microphone (always). Accessibility (only if you want auto-type). |
# Python backend
pip3 install mlx-whisper
# ffmpeg
brew install ffmpeg
# Pre-cache a model (whisper-turbo is the default; ~810 MB)
python3 -c "from huggingface_hub import snapshot_download; snapshot_download('mlx-community/whisper-turbo')"git clone https://github.com/griffinwork40/whisper-app.git
cd whisper-app
npm install
npm run build
npm startThe tray icon appears in your menu bar. The default hotkey is β₯β (hold-to-talk). The first time you hold it, macOS will prompt for Microphone access (and Accessibility, if you've selected autotype).
Settings live at ~/Library/Application Support/whisper-app/config.json:
Short start/stop chimes play when recording begins and ends. Toggle them from the tray menu's Play sounds item (persists to config.json as playSounds). The cues live at assets/sounds/{start,stop}.aiff and are regenerated by the optional scripts/gen-sounds.py dev tool.
The tray menu's Hotkey Mode group lets you pick between:
- Hold to talk (default) β press the chord to start, release to stop. Only works for modifier-only hotkeys (e.g.
Option+Cmd,Alt), because Electron cannot observe key-release on standard accelerators. - Tap to toggle β press once to start, press again to stop. Works for any hotkey, modifier-only or standard (e.g.
Control+Alt+Shift+D).
If you choose Hold to talk with a non-modifier hotkey (e.g. Alt+Space), the app falls back to Tap to toggle and logs a warning. The selection persists to config.json as hotkeyMode: "hold" | "tap".
Recording is hotkey-only by design. Clicking the tray icon opens the context menu β it does not start a recording.
Anything from mlx-community/* that's already cached will work. The tray menu currently exposes:
whisper-tiny(fastest, lowest quality)whisper-turbo(default)whisper-large-v3-turbowhisper-large-v3-mlx
customVocabulary (config-file only, no tray UI yet) is passed to Whisper as its initial_prompt β a short piece of text used to bias transcription toward vocabulary the model might otherwise get wrong: proper nouns, product names, acronyms, or a punctuation/style hint.
"customVocabulary": "Agent AFK, mlx-whisper, uiohook-napi, Griffin Long"This is a nudge, not a guarantee β Whisper truncates it to roughly its last ~220 tokens, so keep it to a short, curated list rather than a paragraph. For a word the model reliably gets wrong the same way every time, a replacement rule (below) is the more reliable fix.
replacementRules is a list of literal (non-regex), case-sensitive find/replace pairs applied to the transcript right before delivery β for words the model consistently mangles the same way, where you want a guaranteed fix rather than a probabilistic nudge.
"replacementRules": [
{ "from": "whisperapp", "to": "whisper-app" },
{ "from": "afk", "to": "AFK" }
]Rules apply in array order; a rule with an empty "from" is ignored.
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Electron Main Process (TypeScript) β
β βββ Tray icon + context menu β
β βββ HotkeyManager (uiohook-napi | globalShortcut)
β βββ AudioRecorder βββΊ ffmpeg child-process β
β β writes β /tmp/whisper-<uuid>.wav β
β βββ Transcriber β
β python3 scripts/transcribe.py β
β β clipboard / AppleScript keystroke β
ββββββββββββββββββββββββββββββββββββββββββββββββ
- Hotkey pressed β
ffmpegstarts capturing 16 kHz mono PCM16 from the default input device. - Hotkey released β ffmpeg flushes the WAV.
scripts/transcribe.pyis spawned with the WAV path and model id. It importsmlx_whisperand prints the transcript to stdout.- Transcript is delivered to clipboard and/or typed into the focused app.
- Temp WAV is deleted.
No network calls at any point.
npm install
npm run build # esbuild β dist/main.js + dist/test/
npm run dev # build + start
npm test # node --test dist/test/**/*.test.js
npx tsc --noEmit # typecheck onlySee CONTRIBUTING.md for the full contributor guide, manual test plan, and project conventions.
Source layout:
| Path | Responsibility |
|---|---|
src/main.ts |
Entry point; wires modules together |
src/tray.ts |
Menu-bar icon, context menu, state machine |
src/hotkey.ts |
Modifier-only (uiohook-napi) + accelerator (globalShortcut) |
src/recorder.ts |
ffmpeg child process β WAV |
src/transcriber.ts |
Spawn transcribe.py, return transcript |
src/output.ts |
Clipboard + AppleScript keystroke |
src/replace.ts |
Literal find/replace on the transcript (replacementRules) |
src/config.ts |
electron-store schema + getters |
src/startup.ts |
Arch / python / mlx_whisper / device / mic checks |
src/logger.ts |
Structured logger |
scripts/transcribe.py |
Python shim around mlx_whisper.transcribe() |
- Code signing + notarization, ship a
.dmg - Streaming / partial transcription
- Per-app output-mode overrides
- Custom dictionary / replacement rules β
customVocabulary(Whisperinitial_prompt) +replacementRules, config-file only for now - Settings UI (currently config file only beyond model + output mode)
PRs welcome β see CONTRIBUTING.md. Release history lives in CHANGELOG.md.
MIT β see LICENSE.
mlx-whisper(Apple)- The original
whisper-dictationproved the concept in Python.
{ "hotkey": "Option+Cmd", // Electron accelerator string "hotkeyMode": "hold", // "hold" (push-to-talk) | "tap" (toggle) "model": "mlx-community/whisper-turbo", // HuggingFace repo id "language": "en", // ISO 639-1 or "auto" "outputMode": "clipboard", // "clipboard" | "autotype" | "both" "playSounds": true, // start/stop audio cues (toggle in tray menu) "pythonPath": "python3", "tempDir": "/tmp", "customVocabulary": "", // see "Custom vocabulary" below "replacementRules": [] // see "Replacement rules" below }