Skip to main content

murmur

Push-to-talk dictation for macOS. Hold Option and speak; words are typed at the cursor of the focused app as they become stable, and the rest is typed when you release. Transcription runs locally with Whisper large-v3-turbo on MLX. See DESIGN.md for the reasoning.

Setup

Requires macOS on Apple Silicon and uv.

uv sync
uv run murmur

The first run downloads the model (about 1.6 GB) into the Hugging Face cache. Later starts load it in a few seconds. Use -v for debug logs.

Permissions

Grant these to the terminal app that runs the command, in System Settings > Privacy & Security, then restart the terminal:

  • Accessibility: sends the synthetic Cmd+V.
  • Input Monitoring: global Option key listening.
  • Microphone: prompted on first recording.

The app prints a warning at startup for Accessibility and Input Monitoring if they look missing.

Recording indicator

The macOS mic indicator does not appear for a LaunchAgent, so the app draws its own. Set indicator in the config (default "overlay"):

  • overlay: a small dark pill with a red dot and "REC" at the top center of the main screen, just below the menu bar and notch. It appears when the 1 s hold threshold passes (not at key down), shows an orange dot and "..." while the final pass runs, then hides. It is a borderless non-activating panel at status window level on all Spaces; it ignores the mouse and never takes focus, so typing continues in the app you are using. No sounds. indicator_position moves it: top-center, top-left, top-right, bottom-center, bottom-left, bottom-right.
  • menubar: a text item ("mic", "REC", "...") in the menu bar via rumps (uv sync --extra menubar). It permanently uses menu bar space, and macOS hides status items that do not fit next to the notch. On the development Mac the item was created but macOS did not put it on screen because the bar was full, which is why the overlay is the default.
  • none: no indicator.

AppKit runs on the main thread and dictation on other threads. The app uses the accessory activation policy, so there is no Dock icon. On startup the menu bar variant activates once (a rumps default). SIGTERM and Ctrl+C end the app with exit status 0 under both indicators. After changing indicator under the agent, run launchctl kickstart -k gui/$(id -u)/com.user.murmur.

Run at login

A LaunchAgent starts dictation at login and keeps it running; hold Option anywhere.

uv sync
uv run murmur install-agent --dry-run   # print the plist and commands only
uv run murmur install-agent             # write the plist, lint it, start the agent
uv run murmur agent-status
uv run murmur uninstall-agent

install-agent writes ~/Library/LaunchAgents/com.user.murmur.plist and runs launchctl bootstrap gui/$UID. The agent runs .venv/bin/python -m murmur directly (not uv run) from the project folder, with RunAtLoad, ProcessType Interactive, and KeepAlive set to restart only after a crash (non-zero exit); a clean exit or Ctrl+C-style stop stays stopped. HF_HUB_OFFLINE=1 is set so login does not need the network; run the app once in a terminal after changing model_repo so the new model is downloaded. --no-start writes the plist without running launchctl.

Logs: tail -f ~/Library/Logs/murmur.log (stdout and stderr, including permission warnings).

After editing the config: launchctl kickstart -k gui/$(id -u)/com.user.murmur

Permissions: with no terminal attached, macOS attributes the permissions to the Python binary itself, not to your terminal. install-agent prints its path after installing, with symlinks resolved (for example ~/.local/share/uv/python/cpython-3.12.x-macos-aarch64-none/bin/python3.12). In System Settings > Privacy & Security add that binary to Accessibility, Input Monitoring and Microphone (click +, press Cmd+Shift+G, paste the path), then run the kickstart command above. At startup the app requests the prompts for itself and warns in the log, naming the binary, when Accessibility or Input Monitoring is missing or the microphone returns only silence.

After a Python upgrade: uv-managed Python lives in a versioned folder, so a new patch release is a new binary and loses the grants. Run uv run murmur install-agent again to print the new path, add it to the three lists, remove the stale entry, and kickstart. The trade-off: this path is stable until uv upgrades Python for the project (uv python pin or a fixed .python-version such as 3.12.13 limits that), whereas a system or Homebrew Python resolves to a versioned path as well, and a signed app bundle would give a permanent identity at the cost of packaging work this project does not do.

Idle cost with large-v3-turbo loaded, measured on the M4 Pro as a normal foreground process (.venv/bin/python -m murmur, no input):

Measure Value
Resident memory (RSS) 1927 MB, unchanged after 60 s
Memory footprint (footprint) 2595 MB, of which 2397 MB is GPU buffers (IOAccelerator)
CPU over 60 s 0.09% of one core (0.05 s of CPU time)
Threads 28
Peak during streaming (43 s clip, real-time feed) RSS 2017 MB, footprint 4101 MB

The footprint peak includes MLX's GPU buffer cache growing across passes of different lengths; it is not released while the process runs.

Behavior

Streaming is on by default. While Option is held, the buffer is re-transcribed about every half second. Words that match in the same position on two consecutive passes are typed; the last word waits until it stabilizes or you release. Typed text is never edited, so a word that Whisper revises after it was typed stays as typed, and only the remaining tail is appended on release (mismatches are logged). Streamed text is typed with key events, not pasted. Set stream = false to transcribe only on release and use insert_mode.

  • Holds shorter than min_hold_ms (1000) do nothing, so a tap or Option symbol typing never starts dictation. Audio is buffered from key down, so the first second is kept once the threshold passes. Nothing is typed and no start sound plays before the threshold; a shorter hold discards the audio.
  • Pressing any other key while Option is held cancels the recording.
  • Recordings stop at max_record_s (120) and are transcribed.
  • Near-silent clips and empty transcripts insert nothing.
  • Pressing Option while a clip is transcribing is ignored with a log line.
  • Each dictation logs the recorded duration and the transcription time.
  • Paste mode restores the previous clipboard as plain text only; images or rich content on the clipboard are lost.

Config

Optional file at ~/.config/murmur/config.toml. Any key can be omitted. Unknown keys and values of the wrong type (for example min_hold_ms = "1000") stop the app at startup with an error naming the key.

Key Default Meaning
model_repo mlx-community/whisper-large-v3-turbo Hugging Face MLX Whisper repo
language "en" Forced language
min_hold_ms 1000 Minimum hold to count
max_record_s 120 Maximum recording length
silence_rms 0.005 RMS below this counts as silence
option_keys "both" "left", "right" or "both"
insert_mode "paste" "paste" or "type"; used only when stream = false
paste_restore_delay_ms 150 Wait before restoring the clipboard
sounds false Start sound (when the hold threshold passes) and stop sound (on release); off because the macOS mic indicator shows recording
start_sound, stop_sound Tink, Pop Paths under /System/Library/Sounds
stream true Type stable words while Option is held
stream_interval 0.5 Seconds between pass starts; a pass takes about 0.45 s, so passes run nearly back to back
stream_holdback_words 1 Words held back until stable or release
stream_trim_s 20 Buffer length that triggers a trim at the last committed word
stream_min_speech_s 0.3 New speech needed to run a pass
stream_flush_silence_s 0.7 Pause that flushes held-back words and the unsettled tail while Option is held
indicator "overlay" "overlay", "menubar" or "none"
indicator_position "top-center" Overlay position

Pause flush

When the buffer ends in at least stream_flush_silence_s of silence (RMS at or below silence_rms) and there was speech since the last flush, one pass runs and every word it heard is typed, so the end of a sentence appears while Option is still held. Text already typed is not edited. Passes cut leading silence to 0.2 s and flush and final passes cut trailing silence to 0.2 s, because Whisper invents a word such as "Okay," over long silence. In a noisy room where the background is above silence_rms, no pause is detected and words are typed on release as before.

Measured on a 48 s clip (the 43 s clip with 1.5 s pauses after "priorities." and "Tuesday." and 2 s of silence before release), real-time feed, real model:

Setting Lag of the last word before a pause Mean lag Typed before release Similarity to spoken text
flush off 3.26 s and 3.98 s 1.21 s 132 of 133 1.000
flush on (default) 1.25 s and 1.62 s 1.16 s 133 of 133 1.000

The lag at a pause includes the 0.7 s wait. The 1 s hold threshold moves the first text on the 43 s clip from 1.2 s to 1.9 s.

Streaming latency

Measured with a 43 s say clip fed in real time through the real Dictation loop and model on an M4 Pro (lag is the time from a word's spoken end, per Whisper word timestamps, to it being typed):

Setting First text Mean lag 90th percentile Release to done
interval 1.0, holdback 2, speech 0.5 2.8 s 2.33 s 2.73 s 1.67 s
interval 0.5, holdback 1, speech 0.5 1.7 s 1.5 s 2.1 s 0.45 s
interval 0.5, holdback 1, speech 0.3 (default) 1.2 s 1.22 s 1.56 s 0.57 s

Word similarity to the spoken text was 1.000 in all three rows (0.989 against Whisper's own full-clip transcript, which splits "on-call" differently). Lag is bounded by the pass time (about 0.45 s, fixed because Whisper always encodes a 30 s window) times the two passes a word needs to be seen, plus the held-back word. Intervals below 0.5 s gave no meaningful gain (mean lag 1.45 s at 0.3 s). Holdback 0 gave no gain either. Lowering the interval raises GPU load while Option is held.

Tried and rejected: decoding only the uncommitted tail plus 3 s of overlap did not make passes faster (cost is flat in audio length) and lost words at the window edge (115 of 134 words typed, similarity 0.92). Committing after one pass for words followed by three more words with probability of at least 0.9 needs word timestamps (0.4 s more per pass), and typed 107 of 134 words (similarity 0.88). Lowering the temperature or the decode length changed nothing measurable.

Troubleshooting

Logs go to the terminal at INFO level: Option down and up, every streaming pass with its text, and every insert with the text and the number of key events posted. If a pass or an insert runs longer than 10 s, all thread stacks are logged as an error; include that in a bug report.

Text is typed with Quartz keyboard events (flags cleared, 20 UTF-16 units per event), so a held Option key does not alter the characters. Key events injected by software never cancel a recording.

Swapping the model

Set model_repo in the config file, for example:

model_repo = "mlx-community/whisper-medium.en-mlx"

Any mlx-community Whisper repo works. language is passed through, so set it to match multilingual models or leave "en" for English.

Development

uv run pytest
uv run ruff check

License and credits

murmur is released under the MIT License (see LICENSE).

It runs OpenAI Whisper models (code and weights are MIT licensed) through mlx-whisper (MIT), using the mlx-community/whisper-large-v3-turbo conversion from Hugging Face. The model is downloaded at first run and is not bundled with this package.

Other dependencies keep their own licenses: pynput (LGPL-3.0), sounddevice (MIT), numpy (BSD), PyObjC (MIT) and rumps (BSD, optional).

Metadata

Release files for murmur-dictation 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for murmur-dictation 0.1.1
File Size Uploaded
murmur_dictation-0.1.1.tar.gz 25.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for murmur-dictation 0.1.1
File Interpreter ABI Platform
murmur_dictation-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 57.8 kB

Release files / murmur_dictation-0.1.1.tar.gz

Download URL murmur_dictation-0.1.1.tar.gz
Size 25.9 kB
Tags Source
SHA-256 checksum
How to use checksums
1563d84c981e0cbac38903c3a55a42e213d2d9d7f55169d20783559609cd3a78
BLAKE2b-256 checksum
How to use checksums
dafa17ac2e2b4078bdb92262a97aa7ccb5cf2d8ebb27c4e5f8e7d5bc5549ca2d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / murmur_dictation-0.1.1-py3-none-any.whl

Download URL murmur_dictation-0.1.1-py3-none-any.whl
Size 31.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4cfb726e1a0845196d77e1058d9a8f0397df68fe21105524db59dcc1522e21ad
BLAKE2b-256 checksum
How to use checksums
c5c442de49d270a4daa1919edc50004f4e8bb7d7937fab7c4b325847c6c43cd4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.10 {"installer":{"name":"uv","version":"0.12.10","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page