Voice dictation for macOS with five ASR backends selectable at runtime:
- Local Whisper —
mlx-community/whisper-large-v3-turboruns in-process via MLX on Apple Silicon (~1.8 GB RAM, works fully offline, 100 languages). - Local Whisper Podlodka fp16 / q8 —
evilfreelancer/whisper-podlodka-turbo-MLX, a whisper-large-v3-turbo fine-tune focused on Russian + English with punctuation, running in-process via MLX at two quantizations (~1.8 GB / ~1.1 GB RAM respectively). Same 100-language support as stock Whisper; strongest on ru/en. - Local GigaAM —
ai-babai/gigaam-multilingual-mlx(FP16), a multilingual CTC model running in-process via MLX (~1.4 GB RAM, fully offline, Russian / English / Kazakh / Kyrgyz / Uzbek auto-detected). - API — any OpenAI-compatible
/v1/audio/transcriptionsendpoint (your gateway, a hosted service, …). No ASR model loaded; only audio is uploaded per recording.
Press Right Option → speak → press Right Option again. Recognized text is typed character-by-character into the focused window — including Citrix Workspace sessions that don't share the clipboard. A second, instant insertion method (Instant paste: clipboard + simulated Cmd+V) is selectable in the menu for everyday apps where speed matters more than remote-desktop compatibility.
- 100 ISO-639-1 languages +
Auto-detect— applies to the Whisper, Podlodka and API backends (GigaAM detects its five languages on its own, so the language menu is disabled while it is active) - Settings (including API credentials) persist at
~/.config/dictate-mac/config.jsonwith mode0o600; the API key is never logged - Menu bar app, default invocation
dictate-mac; CLI daemondictate-mac daemonfor SSH/CI
- Requirements
- Install
- Run from source
- Build the
.appfrom source - Usage
- The menu bar
- Recognition language
- Choosing your backend: Whisper vs Podlodka vs GigaAM vs API
- The config file
- CLI subcommands
- How it works
- Verifying & troubleshooting
- Uninstall & known limitations
- Development
To run dictate-mac:
- Apple Silicon Mac (M1 / M2 / M3 / M4)
- macOS 13 Ventura or newer
- 8 GB RAM or more
- Any working microphone (the built-in one is fine)
- ~284 MB for the
.appitself, plus an optional download for the selected local backend's weights (~1.5 GB Whisper, ~1.5 GB Podlodka fp16, ~0.8 GB Podlodka q8, or ~1.2 GB GigaAM; downloaded on first launch of the matching local mode; API mode needs no extra disk) - Internet access only for the first local-model download — API mode needs none
To run from source you additionally need:
- Python 3.13 — the simplest install path is
uv; it manages the project venv and the runtime deps in one shot.
To build the .app from source you additionally need the Xcode
Command Line Tools (xcode-select --install).
The recommended path: take the prebuilt DictateMac.app from the
GitHub release, copy it into /Applications, and open it.
DictateMac-v0.9.0-macos.zip
from the latest release.
Compressed download is ~93 MB; the extracted .app is ~284 MB.
Verify the download with the bundled .sha256:
shasum -a 256 DictateMac-v0.9.0-macos.zip
# compare with the contents of the .sha256 file from the same releaseDouble-click the zip in Finder, or from a terminal:
open DictateMac-v0.9.0-macos.zip # expands to ./DictateMac.app
mv DictateMac.app /Applications/ # optional — keeps the .app
# alongside your other apps
open /Applications/DictateMac.appFirst-launch Gatekeeper. The
.appis unsigned and ad-hoc-notarised. If macOS refuses to open it, right-clickDictateMac.appin Finder → Open → confirm in the dialog. Subsequent launches are double-click as usual.
macOS prompts for Microphone the very first time you press Right Option; Accessibility and Input Monitoring are granted by hand in System Settings → Privacy & Security. If Input Monitoring is missing at launch, the daemon logs an error and exits — grant it and reopen the app. See the Permissions row in the menu for one-click shortcuts to each pane, and Troubleshooting for fixes if any are still missing.
The default is Local Whisper — the in-process Whisper model. You don't have to do anything; the app works offline out of the box.
To use the Podlodka fine-tune instead (stronger Russian punctuation, ru/en focus), click the menu icon → Model (changing will restart app) → Local Whisper Podlodka fp16 or Local Whisper Podlodka q8. The app restarts and downloads the variant's weights on first use. q8 uses about half the RAM of fp16 with near-identical quality.
To use the GigaAM multilingual CTC model (strong for Russian, plus English / Kazakh / Kyrgyz / Uzbek), click the menu icon → Model (changing will restart app) → Local GigaAM. The app restarts and downloads the ~1.2 GB weights on first use.
To use an external service instead:
- Click the menu icon → Model (changing will restart app) → API.
- Fill in Endpoint (e.g.
https://<your-host>/v1), API key, and Model ID the gateway should use. - Click OK. The dialog GETs
{endpoint}/modelsto verify the endpoint and the model id. On success the values are saved and the daemon restarts so the API backend takes effect at boot.
To switch back, pick Local Whisper in the same menu. See Choosing your backend for full details on validation, error messages, and the config file.
Click the menu bar icon → Recognition language → Auto-detect or any of the 100 ISO-639-1 codes. The choice persists and applies to the next recording — no model reload. The API backend also forwards it per request (see API). In GigaAM mode this menu is disabled — the model auto-detects its five supported languages on its own.
Press Right Option, speak, press Right Option again. The recognised text is typed character-by-character into the currently focused window — including Citrix Workspace sessions that don't forward the macOS clipboard.
If you want the development loop — instant edits to source with no rebuild cycle, or a CLI daemon over SSH — install from source:
cd ~/dictate-mac
uv venv --python 3.13 .venv
uv pip install -e .
dictate-mac --helpThe CLI subcommands are usable straight from the venv:
dictate-mac # menu bar app (default)
dictate-mac daemon # CLI mode, no menu bar — for SSH/CI/tmux
dictate-mac warmup # download + load the model, then exit
dictate-mac selftest # headless smoke checksThe tradeoff vs. the .app install: macOS TCC permissions
(Microphone, Accessibility, Input Monitoring) attach to the Python
interpreter running the script (or to Terminal/iTerm when you use
daemon from a shell), not to a stable bundle id. If you run from
the venv and later switch to the .app, grant the permissions once
more for com.local.dictate-mac.
Most users will not need this — the Install path above
pulls a prebuilt .app from the GitHub release. Rebuild from source
only if you want to run a private fork, ship your own signed /
notarised variant, or inspect what py2app produced.
./build.sh
open dist/DictateMac.app./build.sh regenerates the .icns icon and runs py2app.build_app.
The result is fully self-contained — everything except the ~1.5 GB
local Whisper weights (downloaded at first launch) is bundled.
Bundle size: ~284 MB. Dev-side details (stubs, post-build strip
pass, install_name / signature rewrites) are documented inline in
setup.py and build.sh.
| Action | What happens |
|---|---|
| Press Right Option | Recording starts (you'll hear a short Ping sound) |
| Speak | Your voice is captured |
| Press Right Option again | Recording stops; recognition runs; text is typed |
| Press Esc while recording | Recording is cancelled — nothing is recognised or typed |
| Recognition fails | A short error marker is typed instead, e.g. [dictate: ASR endpoint returned HTTP 503] |
- Use the right Option key specifically — not the left one. If Cmd / Ctrl / Shift is held at the same time, the press is ignored so system shortcuts aren't broken.
- Minimum speech length: 0.3 s — shorter recordings produce no output.
- Audio feedback — Recording start plays Ping, recording stop plays Pop (instant, before VAD/ASR/typing). Cancelling with Esc also plays Pop. Mute in macOS Sound settings if you don't want them.
- Spacing — dictate-mac appends a single space after every recognised phrase so the next dictation doesn't run into the previous one. Press Backspace after the text if you don't want a space.
- Where the text goes — TextEdit, Pages, Notes, browsers (address bar + form fields), Slack, Telegram, VSCode, terminals, Citrix Workspace / Citrix Viewer.
- Two insertion methods (menu → Text insertion method):
- Type keystrokes (for remote desktops) — the default. Text is typed character-by-character via CGEvent Unicode events, which tunnels through Citrix/RDP/VNC sessions that don't share the macOS clipboard. Costs ~8 ms per character, so long dictations take a moment to appear.
- Instant paste (Cmd+V) — the text is placed on the clipboard and a simulated Cmd+V pastes it in one shot. Instant regardless of length. Does not work in remote-desktop sessions without a shared clipboard. The companion toggle Restore clipboard after paste (on by default) returns the previous clipboard contents ~0.25 s after the paste — only string content is preserved, so images/files previously copied are lost. Turn the toggle off to keep the transcript on the clipboard for manual re-pasting.
After dictate-mac (no arguments) launches, the icon (SF Symbol
waveform, template — adapts to dark/light menu bar) appears in the
top-right.
Status: Ready — press Right Option to start and stop recording
─────────────────
Model (changing will restart app)
✓ Local Whisper (mlx-community/whisper-large-v3-turbo)
Local Whisper Podlodka fp16 (evilfreelancer/whisper-podlodka-turbo-MLX)
Local Whisper Podlodka q8 (evilfreelancer/whisper-podlodka-turbo-MLX)
Local GigaAM (ai-babai/gigaam-multilingual-mlx)
API (https://<your-host>/v1)
─────────────────
Recognition language: Auto-detect
─────────────────
Text insertion method
✓ Type keystrokes (for remote desktops)
Instant paste (Cmd+V)
✓ Restore clipboard after paste
─────────────────
Permissions (reset permissions and restart app if not working)
Input Monitoring
Microphone
Accessibility
─────────────────
Open log file
Open log folder (contains last recording)
About
Restart
Quit
| Item | Behaviour |
|---|---|
Status: <state> |
Disabled, read-only. Updated every 0.5 s. |
Model (changing will restart app) |
Disabled, descriptive header. |
Local Whisper (...) |
Clickable. Switches to the local mlx-whisper backend and restarts the daemon. Persists to ~/.config/dictate-mac/config.json. |
Local Whisper Podlodka fp16 (...) |
Clickable. Switches to the Whisper-Podlodka-Turbo fine-tune (ru/en with punctuation) at full fp16 precision and restarts the daemon. |
Local Whisper Podlodka q8 (...) |
Clickable. Same model at 8-bit quantization — about half the RAM of fp16 with near-identical quality. |
Local GigaAM (...) |
Clickable. Switches to the local GigaAM multilingual CTC backend and restarts the daemon. The Recognition-language menu is disabled while this backend is active (the model has no language parameter). |
API (<endpoint>) |
Clickable. Opens the API credentials dialog (Endpoint / API key / Model ID). On a successful OK the dialog GETs <endpoint>/models to verify the endpoint + model id, persists the values, switches the active backend to API, and restarts the daemon. |
Recognition language: <X> |
Clickable. Opens a submenu of Auto-detect and the 100 ISO-639-1 languages Whisper supports. Selecting one persists the choice and applies to the next recording (no restart). |
Text insertion method |
Disabled, descriptive header. |
Type keystrokes (for remote desktops) |
Clickable. Default. Types character-by-character via CGEvent Unicode events — the only method that works in Citrix/RDP/VNC sessions without a shared clipboard. Persists to the config file; applies to the next dictation, no restart. |
Instant paste (Cmd+V) |
Clickable. Places the recognized text on the clipboard and pastes it with a simulated Cmd+V — instant for any text length. Persists to the config file; applies to the next dictation, no restart. |
Restore clipboard after paste |
Checkbox, enabled only while Instant paste is active. Checked (default): the previous clipboard contents are restored ~0.25 s after the paste (string content only). Unchecked: the transcript stays on the clipboard, so a manual Cmd+V pastes it again. Persists; no restart. |
Permissions (… ) header |
Disabled, descriptive label. |
Input Monitoring |
Opens System Settings → Privacy & Security → Input Monitoring. |
Microphone |
Opens System Settings → Privacy & Security → Microphone. |
Accessibility |
Opens System Settings → Privacy & Security → Accessibility. |
Open log file |
Opens the daemon log file (~/Library/Logs/dictate-mac/dictate-mac.log for the bundled .app) in the default app for .log. In CLI mode logs go to stderr, so the parent directory (or Console.app) is opened instead. |
Open log folder (contains last recording) |
Opens ~/Library/Logs/dictate-mac/ in Finder. Besides the log it holds last-recording.wav — the last VAD-trimmed buffer that was sent to the ASR backend (16 kHz mono, overwritten on every dictation). Handy for debugging bad recognition: replay exactly what the model heard. |
About |
Opens https://github.com/vokasug/dictate-mac in the default browser. |
Restart |
Quits the app and re-opens it (via detached osascript). |
Quit |
⌘Q (AppKit auto-renders the shortcut on the right). |
<state> reflects the live daemon state:
Starting…— process launched, running import-time setup.Downloading whisper model…/Downloading Podlodka fp16 model…/Downloading Podlodka q8 model…/Downloading GigaAM model…— first-run HF download of the active local backend (skipped in API mode and on subsequent launches).Loading whisper into RAM…/Loading Podlodka fp16 into RAM…/Loading Podlodka q8 into RAM…/Loading GigaAM into RAM…— model weights are being loaded (~30–60 s cold, ~2 s from cache). Skipped in API mode.Ready— Right Option is armed. silero-vad is NOT pre-loaded here — it warms up lazily on the first recording.Recording…— recording is in progress.Transcribing…— ASR backend is running on the captured clip.Typing…— recognised text is being injected keystroke by keystroke.Error: see logs— something went wrong; the menu and logs describe it. A failed model download/load is retryable in place: the menu showsModel download failed — press Right Option to retryand the next Right Option press re-runs the warmup. Only permission errors require Quit + reopen.
The fourth row of the menu (Recognition language: <X>) opens a
submenu of choices. The currently-selected option is prefixed with a
checkmark (✓); clicking another entry makes that the new default.
The choice is persisted between launches — no restart, no model
reload. For the API backend the choice is also forwarded on every
request (see API). The menu is disabled while the GigaAM
backend is active: GigaAM is a fixed multilingual CTC model
(Russian / English / Kazakh / Kyrgyz / Uzbek) with no language
parameter — it detects the language from the audio itself.
The submenu lists Auto-detect first, then the 100 ISO-639-1
languages Whisper supports, sorted alphabetically by their English
display name (Russian, English, German, …).
The backends share the same audio pipeline (PortAudio → silero-vad → trimmed buffer) — the only difference is what happens in the Transcribing step.
- What it does: runs the in-process mlx-whisper
(
mlx-community/whisper-large-v3-turbo). - Cost: ~1.8–2.0 GB RAM resident after the first warmup (~1.6 GB of weights), plus a brief transient decode peak that is returned to the OS right after each dictation.
- Disk: ~1.5 GB Whisper weights downloaded on first launch to
~/.cache/huggingface/hub/. Cached for subsequent launches. - Network: none after the first download — works fully offline.
- Languages: 100 ISO-639-1 languages + auto-detect, selectable in the Recognition-language menu.
- Output: punctuation and capitalization included. Recognition style stays consistent across long recordings: previous-window text conditions the next window, the decode retries low-confidence windows at temperatures 0.2/0.4 (best of 3 trajectories), and a per-language style prompt steers punctuation and capitalization when one of the 30 supported languages is pinned in the language menu.
- Speed: ~3 s per recording on M1.
- Restart on switch: clicking another backend in the menu restarts the daemon (the new backend takes effect at boot).
- What it is:
evilfreelancer/whisper-podlodka-turbo-MLX— an MLX conversion of the Whisper-Podlodka-Turbo fine-tune (bond005/whisper-podlodka-turbo, base: whisper-large-v3-turbo) trained for Russian + English with punctuation and robustness. Same architecture as stock Whisper, so all 100 languages still work — just weaker outside ru/en than the stock model. - What it does: runs in-process via the same mlx-whisper path as Local Whisper, including the decode options and per-language style prompts. The weights revision is pinned in the source, so upstream re-uploads can never silently change behaviour.
- Quantizations: two menu rows map to the repo's subfolders: fp16 (~1.5 GB download, ~1.8 GB RAM) and q8 (8-bit, group size 64; ~0.8 GB download, ~1.1 GB RAM). q8 is near-lossless for ASR quality and is the better default when RAM is tight; fp16 keeps full fine-tune precision.
- Disk: only the selected variant's subfolder is downloaded to
~/.cache/huggingface/hub/. Switching variants later downloads the other one. - Network: none after the first download — works fully offline.
- Languages: same menu as stock Whisper; strongest on Russian (punctuated, capitalised output) and English.
- Restart on switch: same as the other backends — a menu click restarts the daemon.
- What it does: runs the in-process GigaAM multilingual CTC
model (
ai-babai/gigaam-multilingual-mlx, FP16 artifact) via thegigaam-multilingual-mlxpackage. - Cost: ~1.4 GB RAM permanently after first warmup.
- Disk: ~1.2 GB weights downloaded on first launch to
~/.cache/huggingface/hub/. Cached for subsequent launches. - Network: none after the first download — works fully offline.
- Languages: Russian, English, Kazakh, Kyrgyz, Uzbek — auto-detected per recording; the Recognition-language menu is disabled in this mode.
- Output: lowercase text without punctuation (CTC greedy decoding). Significantly faster than Whisper on the core languages in the model's published benchmark.
- Long dictations: buffers longer than 20 s are decoded in overlapping 20-second windows and stitched automatically (the model is trained on short utterances, so this keeps quality stable on multi-minute recordings). Rare duplicated words can appear at window boundaries.
- Restart on switch: same as Whisper — a menu click restarts the daemon.
-
What it does: POSTs 16 kHz mono WAV to
{endpoint}/audio/transcriptionswithAuthorization: Bearer <key>andmodel=<id>. The same silero-vad trim runs upstream, so the gateway only ever receives speech. -
Cost: zero extra RAM (no model loaded).
-
Disk: no ASR weights.
-
Network: every recording.
-
Speed: round-trip to gateway (typically 0.3–1 s).
-
On failure: a short error marker is typed into the focused window instead of text, e.g.
[dictate: ASR endpoint returned HTTP 503]— the response body is stripped and any other message is truncated, so the marker stays one line. -
Config: the API menu row opens a dialog with three fields (Endpoint, API key, Model ID). On OK the dialog GETs
{endpoint}/modelswith the same bearer token and verifies the configured Model ID appears in the response'sdataarray. Failures surface as categorised messages:Symptom Message wrong API key Authentication failed — check the API key (HTTP 401)endpoint without /v1(or wrong host)Models endpoint not found — confirm the URL ends with /v1 (current: …)model id not offered by the gateway Model ID '<id>' not found at <endpoint> (response listed N model(s))network unreachable / DNS / timeout Could not reach <endpoint>: <reason>The API key is never logged or written into any error message — only the endpoint, HTTP status, and a truncated response body.
-
Switching back to a local backend does not erase the saved credentials — they stay on disk for re-enabling later.
-
Language forwarding: when a language is set in the menu it's sent as the
languageform field on every API request, so the gateway skips its own detection. WithAuto-detectthe field is omitted.
Switching backends always triggers a self-restart (~0.5 s) so the new mode takes effect at boot. The menu click does this automatically; you are dropped into the freshly-launched bundle with the new backend active. The credentials dialog reopens pre-populated on subsequent calls (the key field shows bullets, not the plain value — click the eye to reveal).
All settings (language, backend, API credentials) live in a single JSON file at:
$XDG_CONFIG_HOME/dictate-mac/config.json
$XDG_CONFIG_HOME defaults to ~/.config/, so on a typical Mac
that resolves to:
~/.config/dictate-mac/config.json
The file is written atomically (os.replace from a sibling .tmp),
with permissions 0o700 on the directory and 0o600 on the file.
Schema v2 contents look like:
{
"_v": 2,
"language": "ru",
"model_kind": "api",
"api_endpoint": "https://<your-host>/v1",
"api_key": "<your-bearer-token>",
"api_model_id": "<your-model-id>",
"output_backend": "quartz",
"paste_restore_clipboard": true
}_vis the schema version (currently2).languageis either an ISO-639-1 code ("ru","en", …) or the sentinel"auto". Ignored whilemodel_kindis"gigaam".model_kindis"local"(default, Whisper),"podlodka_fp16","podlodka_q8","gigaam", or"api".- The remaining three fields are only meaningful with
model_kind=api. output_backendis"quartz"(default, character-by-character typing),"paste"(instant clipboard + Cmd+V insertion), or"osascript"(System Events fallback). Files written before this field existed load as"quartz".paste_restore_clipboard(defaulttrue) controls whether Instant paste restores the previous clipboard contents after inserting. Files without the field load astrue. Only meaningful withoutput_backend="paste".
Older configs written by v0.2.x (schema v1, only language) load
unchanged — the missing fields default to local and empty strings,
and the very next save rewrites the file as v2.
If the file is present but invalid (malformed JSON, a non-string
language field, an unknown model_kind, or a malformed API
endpoint), the menu bar logs a warning, falls back to a fresh
in-memory default, and does not overwrite the file. You can
repair it by hand.
CLI subcommands (daemon, warmup, selftest) take their settings
from CLI flags and do not read or write this file.
dictate-mac (no subcommand) launches the menu bar app. The
following subcommands are also available from both the menu-bar
binary and the source venv:
| Subcommand | Purpose |
|---|---|
daemon |
Plain CLI daemon (no menu bar). Same code path, logs to stderr. |
warmup |
Download the selected local model (if needed), load it, then exit. |
selftest |
Headless smoke checks; --no-mic skips the mic roundtrip. |
Common flags (before or after the subcommand): --quiet,
--log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL},
--output {quartz,paste,osascript}, --language {auto|<iso-639-1>},
--model-kind {local,podlodka_fp16,podlodka_q8,gigaam,api},
--api-endpoint <url>, --api-key <key>, --model-id <id>. The
last three are meaningful only with --model-kind=api; daemon
refuses to start without the three required values when api is
selected. --language is ignored with --model-kind=gigaam (the
model auto-detects its five languages). CLI runs do not read or write
the persisted config file — language and ASR settings are taken from
flags directly.
Examples:
# Local Whisper backend, Russian, this CLI session only (config file untouched):
dictate-mac daemon --language=ru
# Local GigaAM backend (Russian auto-detected by the model):
dictate-mac daemon --model-kind=gigaam
# Podlodka fine-tune at 8-bit quantization, Russian:
dictate-mac daemon --model-kind=podlodka_q8 --language=ru
# Auto-detect for this CLI session (default):
dictate-mac daemon
# API backend — endpoint/key/id must all be provided:
dictate-mac daemon \
--model-kind=api \
--api-endpoint=https://<your-host>/v1 \
--api-key=<your-bearer-token> \
--model-id=<your-model-id>
# Instant paste insertion for this CLI session (menu bar app: use the
# Text insertion menu instead — the choice persists):
dictate-mac daemon --output=paste
# Pre-download the GigaAM weights without starting the daemon:
dictate-mac warmup --model-kind=gigaam --skip-mic-test
# Pre-download the Podlodka q8 weights without starting the daemon:
dictate-mac warmup --model-kind=podlodka_q8 --skip-mic-testWhen the bundled .app is the entry point, the same subcommands are
reachable via the bundle's executable:
/Applications/DictateMac.app/Contents/MacOS/DictateMac selftest --no-micLogs go to ~/Library/Logs/dictate-mac/dictate-mac.log (truncate-on-start)
when launched as a bundle, or to stderr when run from a terminal —
the same logutils module decides based on whether sys.executable
is inside a DictateMac.app bundle. The Open log file menu item
opens the log file in your default .log viewer (Console.app), and
Open log folder (contains last recording) opens the directory in
Finder. Next to the log the daemon keeps last-recording.wav — the
last VAD-trimmed audio buffer sent to the ASR backend, overwritten on
every dictation.
The menu bar app (rumps / NSApp, main thread) polls a thread-safe
state snapshot from an asyncio worker thread that owns the pipeline:
PortAudio recorder → silero-vad trim → the ASR backend selected by
model_kind in the config file → the text injector selected by the
Text insertion method setting (a Quartz CGEvent Unicode typer that
types keystroke-by-keystroke, or an instant clipboard + Cmd+V paste). A
third thread runs the Quartz CGEventTap that watches Right Option (and
Esc as the recording-cancel key) and feeds events into the worker
through a bounded queue.
In a local mode the daemon downloads the model weights (if not
cached) and loads them into RAM before arming the hotkey — Status: Ready only appears after that completes. In API mode the
local-model load is skipped, Ready arrives within a second, and each
recording is POSTed to the configured gateway. silero-vad's ONNX model
loads lazily on the first recording. Whisper recognitions take ~3 s on
M1; GigaAM is typically faster on its five supported languages; API
mode takes only the gateway round-trip (typically 0.3–1 s).
The rationale behind each of these choices is documented inline in the source modules (see Development).
If something looks off, run the headless self-test:
dictate-mac selftest # also records 1.5s from the mic
dictate-mac selftest --no-mic # skip the microphone roundtripIf you installed via the .app rather than from a venv, the same
subcommands are reachable via the bundle's executable
(/Applications/DictateMac.app/Contents/MacOS/DictateMac selftest).
Exit code 0 if all checks pass, 1 otherwise. Each check prints a
PASS/FAIL line with a one-line detail; the checks are
model-load, vad-silence, vad-speech-like, vad-pause-clamp,
asr-smoke,
gigaam-model-load, gigaam-asr-smoke, typer-dispatch,
paste-clipboard-roundtrip,
ssl-certifi, config-v1-migration, config-invalid-endpoint,
config-api-required-when-api, config-local-kinds,
audio-wav-roundtrip, last-recording-wav, api-transcribe-headers,
api-transcribe-auto-language, api-models-check, gigaam-dispatch,
whisper-decode-options, gigaam-chunking, podlodka-dispatch,
warmup-retry, error-marker, recorder-portaudio-retry,
hotkey-escape-event,
podlodka-fp16-model-load, podlodka-fp16-asr-smoke,
podlodka-q8-model-load, podlodka-q8-asr-smoke,
plus an optional mic-roundtrip.
FAQ:
| Symptom | Likely cause | Fix |
|---|---|---|
| Right Option press isn't detected | One of the three TCC permissions missing | Click Input Monitoring in the menu → grant. Quit + reopen the app. For the venv, the grant goes to the Python interpreter instead. |
Status: Error: see logs after first Right Option press |
Microphone not granted | macOS will prompt on first press; if you declined, open System Settings → Privacy & Security → Microphone and toggle dictate-mac on. |
| Russian text doesn't appear in Citrix | Citrix unicode input disabled | In Citrix Viewer → Preferences → Keyboard, enable Send Unicode keyboard input (default in modern versions). Or use --output=osascript. |
| Nothing is inserted in Citrix while Instant paste is selected | The remote session doesn't share the macOS clipboard | Switch the menu → Text insertion method → Type keystrokes (for remote desktops). |
Status stuck on Starting… / Downloading… |
Hugging Face unreachable (local mode), or the bundle can't find embedded Python | curl -I https://huggingface.co/mlx-community/whisper-large-v3-turbo. If the model is downloaded but loading still fails, rebuild with ./build.sh --clean. |
Model download failed — press Right Option to retry |
The first-run download failed (no network, DNS/VPN hiccup, HF outage) | Bring the network up and press Right Option — the warmup re-runs without an app restart. Check the log via Open log file if it keeps failing. |
mlx fails to install / load |
Python 3.14 (no wheel) | Recreate venv: uv venv --python 3.13 .venv --force && uv pip install -e . |
| API mode returns HTTP 429 | The OpenAI-compatible gateway is rate-limiting | Some gateways (e.g. whisper-large-v3-turbo) have a 1-request-per-10-15-seconds cap on certain upstream providers. Switch the menu to a different model id, or to Local if the same model is acceptable. |
| Too much RAM usage | A local backend occupies ~1.1–1.8 GB permanently | API mode doesn't load any ASR weights; Podlodka q8 is the lightest whisper-family option (~1.1 GB). Switch if RAM is tight. |
# 1. Quit the .app (menu → Quit ⌘Q) or Ctrl-C in CLI mode.
# 2. Remove the project (and the .app if you copied it into /Applications)
rm -rf ~/dictate-mac
rm -rf /Applications/DictateMac.app # only if you copied it there
# 3. Remove logs and optional local model caches
rm -rf ~/Library/Logs/dictate-mac
rm -rf ~/.cache/huggingface/hub/models--mlx-community--whisper-large-v3-turbo
rm -rf ~/.cache/huggingface/hub/models--evilfreelancer--whisper-podlodka-turbo-MLX
rm -rf ~/.cache/huggingface/hub/models--ai-babai--gigaam-multilingual-mlxYou can leave the granted Privacy & Security permissions in place —
they don't affect anything once the app is gone. To remove them too,
revoke com.local.dictate-mac in
Privacy & Security → Microphone / Accessibility / Input Monitoring.
Known limitations:
- RAM is held permanently after the first startup when using a local backend: ~1.8–2.0 GB for Whisper, ~1.8 GB for Podlodka fp16, ~1.1 GB for Podlodka q8, ~1.4 GB for GigaAM. The API backend doesn't load any ASR weights into memory; its startup is instantaneous and can run on Mac models where the mlx weights aren't downloaded.
- GigaAM emits lowercase text without punctuation (greedy CTC decoding) and supports only Russian, English, Kazakh, Kyrgyz and Uzbek — use Whisper, Podlodka or API for other languages or for punctuation-rich output.
- The Podlodka fine-tune is strongest on Russian and English; on other languages stock Whisper (or the API backend) remains the better choice.
- macOS TCC permissions (Microphone, Accessibility, Input Monitoring)
must be granted manually. With the bundled
DictateMac.appthey go tocom.local.dictate-mac; with the menu-bar app run from the venv they go to the Python interpreter; withdictate-mac daemonthey go to Terminal/iTerm. One-time per-machine grant. - Citrix Viewer requires Send Unicode keyboard input to be enabled (default in modern versions).
- Instant paste (Cmd+V) temporarily overwrites the clipboard. With Restore clipboard after paste on (default), the previous contents are restored ~0.25 s after the paste, but only string content is preserved — images/files copied earlier are lost. It also does not reach remote-desktop sessions (Citrix, RDP, VNC) without a shared clipboard — keep Type keystrokes for those.
- The app is launched manually in v1 — no LaunchAgent, no auto-start at login.
- The
.appis unsigned, ad-hoc, not notarised. macOS Gatekeeper may prompt on first launch — right-click → Open to bypass the quarantine. - The hotkey tap stays disabled if Input Monitoring was not granted before launch. Grant it in System Settings → Privacy & Security → Input Monitoring, then Quit + reopen DictateMac.
- Switching the ASR backend (Local Whisper ↔ Local Whisper Podlodka ↔ Local GigaAM ↔ API) always triggers a restart. The menu click is automatic.
- Python 3.13.x only; 3.14 is unsupported because
mlxdoes not yet ship a wheel for it. - The bundled
.appcannot transcode audio files (silero_vad.read_audio("foo.wav")andtorchaudio.load()both raiseRuntimeError). All audio in the bundle flows through PortAudio →numpy.ndarray→ silero-vad → whisper — the only path we use. Dev-side rationale and how the stubs work around the upstream torch / torchaudio wheels is documented inline insetup.py.
The source is the documentation: every module in src/dictate_mac/
carries header comments explaining what it owns and why, and
non-obvious decisions are commented at the code they protect
(transcriber.py for the ASR backends, hotkey.py for the Quartz
tap, setup.py + build.sh for the bundle pipeline). Read them
before changing anything. Things that are easy to break and not
obvious from any single file:
- MLX work is pinned to one thread —
transcriber._mlx_executor. Loading a model on one thread and running inference on another crashes withThere is no Stream(gpu, N) in current thread. - No
pynput, no clipboard — typing is raw QuartzCGEventUnicode injection;pynputdrops Cyrillic in some configs and the clipboard doesn't cross the Citrix channel. - certifi must exist on disk in the bundle — a zip-bundled
cacert.pembreaks every HTTPS call (HF downloads, API backend). See_extract_native_runtime_libs/_rewrite_boot_scriptinsetup.py. - The API key is never logged and never appears in error messages —
selftest fakes it as
SECRET_KEY_DO_NOT_LEAKand asserts it never leaks. Keep it that way. - Smoke-test every
.appbuild before shipping:dist/DictateMac.app/Contents/MacOS/DictateMac selftest --no-micmust be all-PASS. The bundled runtime is not the venv — a green source-venv selftest proves nothing about the bundle. - No secrets or personal data in tracked files — no real
credentials, endpoints, names, or
/Users/<name>/…paths; placeholders (<your-host>,~/…) only. - Keep this README describing the current state only — no changelogs or work history — and update it together with any change that alters behaviour.