Skip to content

About

Voice dictation for macOS with five ASR backends: local offline (Whisper, Whisper-Podlodka fp16/q8, GigaAM) or any OpenAI-compatible /v1/audio/transcriptions endpoint. Right Option toggles recording; recognised text is typed character-by-character (works in Citrix/RDP sessions) or pasted instantly via clipboard — selectable in the menu.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

17 Commits

Folders and files

Repository files navigation

dictate-mac 🎙️

Voice dictation for macOS with five ASR backends selectable at runtime:

  • Local Whisper — mlx-community/whisper-large-v3-turbo runs 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/transcriptions endpoint (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.json with mode 0o600; the API key is never logged
  • Menu bar app, default invocation dictate-mac; CLI daemon dictate-mac daemon for SSH/CI

Contents


Requirements

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 .app itself, 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).

Install

The recommended path: take the prebuilt DictateMac.app from the GitHub release, copy it into /Applications, and open it.

1. Download

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 release

2. Install

Double-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.app

First-launch Gatekeeper. The .app is unsigned and ad-hoc-notarised. If macOS refuses to open it, right-click DictateMac.app in Finder → Open → confirm in the dialog. Subsequent launches are double-click as usual.

3. Grant the three permissions

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.

4. Pick your backend

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:

  1. Click the menu icon → Model (changing will restart app) → API.
  2. Fill in Endpoint (e.g. https://<your-host>/v1), API key, and Model ID the gateway should use.
  3. Click OK. The dialog GETs {endpoint}/models to 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.

5. Pick the language

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.

6. Use

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.

Run from source

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 --help

The 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 checks

The 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.

Build the .app from source

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.

Usage

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.

The menu bar

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 shows Model download failed — press Right Option to retry and the next Right Option press re-runs the warmup. Only permission errors require Quit + reopen.

Recognition language

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, …).

Choosing your backend: Whisper vs Podlodka vs GigaAM vs API

The backends share the same audio pipeline (PortAudio → silero-vad → trimmed buffer) — the only difference is what happens in the Transcribing step.

Local Whisper

  • 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).

Local Whisper Podlodka fp16 / q8

  • 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.

Local GigaAM

  • What it does: runs the in-process GigaAM multilingual CTC model (ai-babai/gigaam-multilingual-mlx, FP16 artifact) via the gigaam-multilingual-mlx package.
  • 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.

API

  • What it does: POSTs 16 kHz mono WAV to {endpoint}/audio/transcriptions with Authorization: Bearer <key> and model=<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}/models with the same bearer token and verifies the configured Model ID appears in the response's data array. 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 language form field on every API request, so the gateway skips its own detection. With Auto-detect the field is omitted.

Switching and persistence

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).

The config file

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
}
  • _v is the schema version (currently 2).
  • language is either an ISO-639-1 code ("ru", "en", …) or the sentinel "auto". Ignored while model_kind is "gigaam".
  • model_kind is "local" (default, Whisper), "podlodka_fp16", "podlodka_q8", "gigaam", or "api".
  • The remaining three fields are only meaningful with model_kind=api.
  • output_backend is "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 (default true) controls whether Instant paste restores the previous clipboard contents after inserting. Files without the field load as true. Only meaningful with output_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.

CLI subcommands

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-test

When 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-mic

Logs 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.

How it works

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).

Verifying & troubleshooting

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 roundtrip

If 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.

Uninstall & known limitations

# 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-mlx

You 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.app they go to com.local.dictate-mac; with the menu-bar app run from the venv they go to the Python interpreter; with dictate-mac daemon they 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 .app is 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 mlx does not yet ship a wheel for it.
  • The bundled .app cannot transcode audio files (silero_vad.read_audio("foo.wav") and torchaudio.load() both raise RuntimeError). 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 in setup.py.

Development

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 with There is no Stream(gpu, N) in current thread.
  • No pynput, no clipboard — typing is raw Quartz CGEvent Unicode injection; pynput drops 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.pem breaks every HTTPS call (HF downloads, API backend). See _extract_native_runtime_libs / _rewrite_boot_script in setup.py.
  • The API key is never logged and never appears in error messages — selftest fakes it as SECRET_KEY_DO_NOT_LEAK and asserts it never leaks. Keep it that way.
  • Smoke-test every .app build before shipping: dist/DictateMac.app/Contents/MacOS/DictateMac selftest --no-mic must 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.

About

Voice dictation for macOS with five ASR backends: local offline (Whisper, Whisper-Podlodka fp16/q8, GigaAM) or any OpenAI-compatible /v1/audio/transcriptions endpoint. Right Option toggles recording; recognised text is typed character-by-character (works in Citrix/RDP sessions) or pasted instantly via clipboard — selectable in the menu.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages