Tap a key twice and talk. The words land at your cursor while you are still talking, in whatever app you are already in: Slack, your terminal, a browser, your notes. It transcribes on your own machine, so nothing you say ever leaves it.
A free, local alternative to the paid cloud dictation apps. No account, no subscription, no server, no telemetry, and no Python dependencies at all. macOS and Windows.
The product page · Our other tools · What we do for companies
macOS — paste into Terminal:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/ZyxWorks/murmurflow/main/install.sh)"Windows — paste into PowerShell:
irm https://raw.githubusercontent.com/ZyxWorks/murmurflow/main/install.ps1 | iexEither one works on a machine with nothing on it: no package manager, no Python, no developer tools, and on Windows no Administrator either. It installs what is missing, downloads the speech speech models (~2.1 GB, once), and turns dictation on for every login. On macOS it also opens the one permission switch the OS will not let a script flip for you; Windows has no such switch.
Then double-tap Control, say something, and tap it once more to stop. That's the whole product,
and it is what macOS puts its own dictation on. (Rather hold a key while you talk?
murmurflow config set doubleTap false — then it is Control+Option held together, Windows
Control+Alt.)
| 150 wpm | 40 wpm | 0 € / mo |
|---|---|---|
| how fast you speak, without trying | how fast an average person types | MIT licensed, with no paid tier above it |
A 200-word message is five minutes of typing. Say it instead and you are done in eighty seconds, hands still on the desk. Ten of those a day is most of an hour back, every day.
Averages, not promises: conversational speech runs about 130 to 150 words a minute, and an average typist about 40. Your own numbers are yours to check, and the product page shows the same figures with the measured speed table beside them.
Rather do it by hand?
macOS ships Python 3.9 and MurmurFlow needs 3.11+, so uv (which brings its own) is the path with
the fewest ways to go wrong:
brew install whisper-cpp ffmpeg uv
uv tool install --python 3.13 git+https://github.com/ZyxWorks/murmurflow
murmurflow setup # downloads both speech models (~2.1 GB, once)
murmurflow install # set it up (starts nothing)
murmurflow on # dictation is live now, and after every loginOn Windows the only difference is whisper: there is no package for it, so the installer downloads
whisper.cpp's own release build and unzips it
into %LOCALAPPDATA%\MurmurFlow\bin. Everything else is the same:
winget install Gyan.FFmpeg astral-sh.uv
uv tool install --python 3.13 git+https://github.com/ZyxWorks/murmurflow
murmurflow setup
murmurflow install
murmurflow onIf murmurflow is not found afterwards, ~/.local/bin is not on your PATH — uv tool update-shell fixes that for the next terminal you open.
What is different on Windows
Four things, and none of them is a setting you have to find. The trigger names, the config file,
every command and every message are identical — alt, win, super and meta are accepted
spellings of the same keys, so a config file moves between machines unchanged.
| macOS | Windows | |
|---|---|---|
| permission to type | Accessibility, granted per executable | none — there is nothing to grant |
the trigger fn |
works | not available: the key is handled in keyboard firmware and never reaches the OS |
the trigger right_command |
not available: these Macs report the right-side keys as the left ones | works |
| the clipboard, while a dictation lands | every flavour is restored, images included | text only |
One ceiling worth knowing: Windows does not let an ordinary program type into a window running as Administrator. Dictating into an elevated console needs MurmurFlow running elevated too.
Linux is not supported yet, and that is a decision rather than an oversight. Recording and
typing are both small there; the hotkey is not, because Wayland exposes no global hotkey API at
all. murmurflow doctor runs on Linux and says exactly that.
It types while you talk, and there is no setting for it. Tap twice and start speaking: the
words begin landing at your cursor about two seconds in, in lumps, and the rest arrives when you
tap again to stop. It used to be an opt-in flag called stream and that flag is gone — opt-in
meant almost nobody ever saw the good version of the tool. See
how it works.
It makes exactly two sounds, and neither has a setting. A short tick the moment the microphone is genuinely live, so you know when to start. A blunter one the instant it closes, so you know the last word or two still coming is a wait and not a loss. Nothing marks a failure — that is a line in the log, not a noise in a meeting — and the five configurable tone presets are gone with the setting that chose between them.
And the first word is not missing any more. CoreAudio takes about 0.6s to hand over its first buffer, so a recorder opened on the second tap starts half a sentence late and no decoder can get that audio back. The recorder is now opened on the first tap instead and the second tap claims it, already live — which is also why the tick usually sounds the instant you finish tapping. Measured over a five-second sentence: 0.35s of speech lost before, 0.04s after. A tap that never becomes a pair stops the recorder within 1.5s and deletes what it caught.
What you said is what you get. Nothing is removed, reworded or reordered. If you say "hey, ship it on Friday", "hey" appears. There is an opt-in filler strip, and it removes sounds only — never a word. "hey", "so", "well" and "you know" were on that list once, and each of them is also an ordinary word: "Do you know what time it is?" came back as "Do what time it is?". A word deleted is invisible; an "um" left in costs one keystroke.
murmurflow config set stripFillers true # deletes um / uh / erm / hmm, and nothing elseAnd what you did NOT say never appears. Handed a recording of a room, whisper does not answer "nothing" — it answers fluently, in a language picked at random, and that sentence gets typed into whatever window is in front. Level and confidence catch most of it. Naming the languages you speak catches the rest, and costs nothing (it does not pin the decoder):
murmurflow config set languages '["de", "en"]'The other half of it is the pause between your last word and your hand reaching for the key.
Whisper fills silence with a goodbye — reported as "random thank-yous, I don't know where this
comes from" — so a trailing Thank you., Thanks., Danke., Bye bye or a subtitle credit line
is dropped when it is appended to a sentence that had already ended. Never the last sentence
standing, so a dictation that really is "Thank you." still types. The cost of that trade is real
and one-directional: say "Mach das bitte. Danke." and the Danke goes.
Double-tap, or hold. Double-tap is the default: tap left Control twice to start, tap again
to stop. It is what macOS's own dictation does, it is the only gesture that survives a long
sentence, and it is the safer of the two for the reason in The trigger below. Holding is there
for one short sentence at a time:
murmurflow config set doubleTap false # hold the key while you talk insteadBoth take effect immediately — config set restarts the listener for you.
Something else wants the same double-tap for a moment — a voice assistant taking a turn, a screen recorder that must not have the microphone pulled out from under it. That is a pause, not a stop: the listener stays up and the whisper server stays warm, and only the trigger stands down.
murmurflow pause --seconds 120 --who "a Zyx huddle"
murmurflow resume # or just waitEvery pause expires, and that is the design rather than a safety net. A borrower that crashes
holding the key would otherwise leave dictation silently dead with nothing on screen to explain it,
which is the worst failure this tool can have. The default is five minutes, the ceiling is an hour,
and murmurflow doctor names the holder and the time left for as long as it lasts.
An explicit murmurflow toggle still records while the key is lent — a pause stands the trigger
down, it does not disable dictation.
| gesture | its key | why |
|---|---|---|
| double-tap (default) | left Control | two deliberate taps is a shape no shortcut has |
| hold | ⌃⌥ together | a hold starts on the same key-down a shortcut does, so it needs a combo |
That is one rule, and it is worth understanding before you rebind.
The listener polls key state rather than intercepting it — that is what keeps this
dependency-free and out of Input Monitoring — so it can never take a keypress away from anything
else. A hold on a bare modifier therefore fires on every ⌃C and every ⌃←: the microphone
opens before the chord guard can tell it was a shortcut. It discards the audio correctly, but the
microphone did open.
A double-tap has no such problem. Two taps of one key inside half a second is not a shape any
shortcut has, and the one that could imitate it (⌃C then ⌃C) is thrown out by the chord guard.
So double-tap gets one ordinary key — easier to perform than two at once, and the same key on every
keyboard there is.
murmurflow config set trigger left_control # one key
murmurflow config set trigger command_option # ⌘⌥
murmurflow config set trigger ctrl_alt # the same as control_option, spelled the other wayctrl/alt/win are accepted everywhere control/option/command are, so a config written on
one keyboard reads on another.
macOS has its own "press Control twice for dictation", and it is on by default on many Macs. On a Control trigger both fire, and Apple's microphone panel lands on top of this one.
murmurflow doctorchecks for it and prints the fix; you do not have to remember this. System Settings → Keyboard → Dictation → Shortcut → Off.
Every sentence typed twice? Something else is listening on the same key.
murmurflow doctornames it and prints the one command that stops it. The usual one iszyx voice listen— murmurflow was extracted from zyx, and murmurflow's own lock cannot see another program's daemon.
murmurflow keytest shows what this Mac actually reports for every bindable key. Use it before
believing any of the above about your hardware — some MacBooks report the right-side Command and
Option keys as the left ones, so a right_* trigger can never fire there.
Dictation is the fastest input method most people never use, and the good implementations are all cloud products: your microphone streams to someone else's servers, behind a subscription, under a privacy policy that can change. The local pieces to do it properly are all right there — whisper.cpp transcribes better than most cloud APIs, and macOS will tell you which keys are held and let you paste into the frontmost app.
So this is a few thousand lines of standard library gluing those together.
Measured on an M4 Pro, macOS 26, large-v3-turbo, an 11 second clip, 8 threads, nothing else
resident:
| step | seconds |
|---|---|
transcribe, warm server, language pinned |
1.2 |
transcribe, warm server, language on auto |
1.9 |
transcribe, cold whisper-cli, model page-cached |
1.7-2.2 |
| first transcription after boot (1.6 GB model off disk) | 13.3 |
| microphone open, first ever | 9.9 |
| microphone open, thereafter | 0.3 |
| speech lost at the start of a clip (pre-roll on) | 0.04 |
Two things in that table are worth more than the headline number:
The warm server is not what makes this fast in steady state. Once the model file is in the OS
page cache, cold and warm are within noise of each other. What a resident whisper-server actually
buys is the first transcription of the day — 13.3s down to 1.2s — which is exactly the one you
form your opinion on, and exactly the one a freshly booted laptop serves. So the daemon starts it at
launch and pays that cost while nobody is waiting.
Pinning the language is the real lever, worth ~0.7s per sentence. It is the one setting most worth changing:
murmurflow config set language enOnly do it if you really do speak one language into it. Pinning the wrong language is worse than
leaving it on auto: forcing en onto German speech makes whisper translate rather than
transcribe, and a fluent English paraphrase of what you said is far more confusing than a slow
transcript. That is why the default stays auto.
These numbers move a lot with your machine and what else is resident — two whisper-servers on one Mac roughly doubled every row. Measure your own before believing any of them, including ours.
- No compiled helper. Key state is polled through
CGEventSourceFlagsStatewith stdlibctypes. No Xcode, no code signing, no notarization, and no TCC grant that a rebuild invalidates. - No dependencies.
pip install murmurflowpulls in nothing. Two Homebrew binaries and macOS itself do the work. - No always-on microphone. Nothing is listening between sentences. Typing the words out while you talk decodes the clip you are already recording, and nothing else — see how the words arrive. The one exception is the half second the recorder opens early, on the first of your two taps, so the first word is not lost; a tap that never becomes a pair stops it and deletes what it caught.
- No LLM on the hot path, unless you ask for one — see polish.
- No Linux. Recording and typing are both small there; the hotkey is not, because Wayland
exposes no global hotkey API at all.
murmurflow doctorruns on Linux and says exactly that.
macOS will ask for two the first time, and neither can be granted from a script:
- Microphone — to hear you.
- Accessibility — to type into the app you're using. System Settings → Privacy & Security → Accessibility
Both rows are called MurmurFlow. murmurflow on builds a small app bundle at
~/Applications/MurmurFlow.app purely so that is true. Without it macOS names the row after the
interpreter — python3.13 — which nobody scrolling for "murmurflow" finds, and switching that on
would hand the microphone and your keyboard to every other Python tool sharing it.
If the key is never detected at all, Input Monitoring is the third — but test with
murmurflow keytest before granting it, because most Macs don't need it.
The apps you dictate into need nothing. Every permission goes to this one program; Slack, your browser and your editor just receive a paste. You are not opening up your machine app by app.
murmurflow doctor answers both questions for real — it asks the bundle, not itself. If dictation
transcribes but nothing appears, Accessibility is the reason nine times out of ten.
Model size is the single biggest lever on proper nouns and jargon. murmurflow setup gets
large-v3-turbo because it's near-large-v3 quality at roughly 8x the speed. Anything smaller is a
real cliff — names come back as plausible nonsense.
Dropping a different ggml file into ~/.murmurflow/models/ upgrades or downgrades it with no config
change: the best model present wins.
murmurflow setup base # smaller and faster, noticeably worseONE model does both jobs — the transcript you keep and the words that arrive while you talk. There used to be a second, small one for the live pass; it was retired when the live pass began typing punctuation. See how the words arrive.
Teach it your own words — the cheapest accuracy win there is:
murmurflow config set vocabulary '["Kubernetes", "Postgres", "Anthropic", "Reinsch"]'~/.murmurflow/config.json, one flat object. murmurflow config prints every key.
| key | what it does |
|---|---|
trigger |
the key. Default follows the gesture: left_control for double-tap, control_option for hold |
doubleTap |
tap twice to start, tap again to stop. On by default. false = hold the key while you talk |
language |
en, de, … Default auto. Pinning saves ~0.7s per sentence, so pin it if you can |
languages |
the languages you actually speak, e.g. ["de","en"]. A clip whisper reads as any other one is dropped. Empty = accept all |
inputName |
part of a microphone name. Default: system default. murmurflow devices lists them |
vocabulary |
proper nouns to bias the transcriber toward |
polishCommand |
see below |
stripFillers |
true = delete the sounds um / uh / erm / hmm, and nothing else. Off — you get verbatim |
quietFloor |
peak dBFS below which a clip is a room and not a sentence. Default -30 |
model |
path to a ggml model file, overriding the best one found in ~/.murmurflow/models/ |
port |
loopback port for the warm whisper-server. Default 8479. The live model's server takes the next one up |
keepAudio |
keep the evidence for one bad transcription: the last clip, and the transcript in the log. Off, so your sentences are not written down |
Right Option is deliberately not offered as a default. On a German layout it's AltGr — the dead
key for @ € \ | ~ [ ] { } — so binding dictation there fires the microphone on every email
address and code bracket.
On by default, with no setting to find. The words land at your cursor in lumps while you are still talking, instead of arriving in one paste when you stop.
What it actually does: it re-decodes the clip you are currently recording, over and over, and types the words that two passes in a row agreed on. Agreement is the safety. Whisper revises — give it another second of audio and it re-reads what it already had, now that it knows how the sentence ends — so typing each pass's best guess would type words the next pass withdraws, and nothing can un-type them. The first pass has nothing to agree with, so it holds back its last four words instead.
ONE model, and it types the punctuation you keep. large-v3-turbo (~1.6 GB) answers the live
pass and writes the final transcript, on one warm server.
There used to be a second, small model on its own server, because it answered a pass in ~0.4s against ~1.5s. It was retired the day the live pass started typing marks as well as words: a full stop lands as soon as a later word confirms it, so the model answering the live pass is the model that decides the punctuation you keep — and there the two are not close. Replayed through the whole streaming loop on one real 38 second clip, against the big model's own whole-clip transcript:
| live model | differs | what lands on screen |
|---|---|---|
| small | 13.9% | ...in the end like when I just stopped my control it just added a lot of gibberish |
| big | 11.4% | ...in the end, like when I just stopped my control, it just added a lot of gibberish, I'm not sure. And that came after a few seconds, after I already sent the message |
The percentages understate it. The sentences are the finding.
And a live word is TYPED, not pasted. The clipboard round trip — save the pasteboard, write the text, send ⌘V, wait for the target to read it, put the old contents back — costs about 500ms, more than decoding the audio did. A unicode key event carries the characters itself: no pasteboard, nothing to settle, 2.6ms.
It is also safe on a German keyboard, which is the reason the clipboard was chosen in the first
place: AppleScript's keystroke sends keycodes that the target re-maps through its own layout and
mangles every umlaut, where CGEventKeyboardSetUnicodeString sends the characters themselves. The
final transcript still goes through the clipboard, because it can be two thousand characters at
once and because its paste reports back what the target actually received.
Measured on an M4 Pro, macOS 26, language on auto:
| clip length so far | one live pass |
|---|---|
| 2s | 1.47s |
| 10s | 1.50s |
| 20s | 1.49s |
| 30s | 2.35s |
| 45s | 2.38s |
So the words arrive in lumps of a second and a half, and in bigger lumps the longer you talk — past 30 seconds whisper.cpp's padding becomes a second 30s window. That is the price of the punctuation, and it is a setting:
murmurflow config set livePass small # faster lumps, thinner punctuationmurmurflow doctor says which model is answering, and every clip's line in the daemon log ends
with what streaming actually did: stream 21x → 20 typed.
Nothing is pinned. Detecting the language costs a whole extra encoder pass, and every pass pays it. Pinning the language to what the first second heard saved ~0.75s a pass and cost the gate that refuses invented speech: whisper-server reports back whatever language it was told to decode, so every pass after the first reported the pinned language by construction, whatever it had actually decoded. A partial is typed, so the gate that judges it has to be able to see.
Silence is cut before anything transcribes it. Whisper invents words when it is handed audio with nothing in it. The same 12 seconds of speech, three ways:
| clip | what came back |
|---|---|
| speech alone | ...but just in this text box, |
| + 20s of digital silence | ...but just in this text box, Thank you. |
| + 20s of faint room noise | ...but just in this text box.. |
So the invention is the silence, not the speech. A word list cannot catch it — whisper answers
silence in a different invented language each time — and neither can whisper's own per-segment
no_speech_prob: the invented "Thank you." came back at 0.000, sitting among real speech.
The cure is not to hand the silence over.
Two things to know:
- It needs the tap gesture, and that is not a preference. A paste is a synthetic ⌘V, and in
hold-to-talk the trigger is a modifier that is physically down — every paste would be sent as
⌥⌘V into whatever app you are in. With
doubleTap falseit stands down, and the daemon says so on the line it prints at start-up. - It needs the warm whisper-server. Partials never fall back to the cold
whisper-cli: that would spawn a model on every pass and make your final transcription slower, not faster. No warm server, and the words simply arrive at the end as they always did. - The microphone closes itself. After 15 seconds with nothing said, or 120 seconds either way,
the clip is finished exactly as your second tap would have finished it — the words are typed, not
thrown away.
silenceStopandmaxHoldmove both;0switches either off. - The last pass can still reword what is already typed. Usually punctuation or a capital. There is no un-paste and deliberately no attempt at one: synthesising backspaces into an app whose cursor may have moved since would delete text that was never ours. A word left as first heard is a cosmetic loss; a doubled half-sentence is not.
Shrinking the encoder window per request (audio_ctx) was tried instead and thrown out: it does
cut a pass to 0.8s, and on some clips it returns
fluent invented text — "the final pass has to line a line. So the final pass has to line a line" —
at every window size tried, deterministically enough that two passes agree on it and it gets typed.
Fast and occasionally making things up is the one trade a dictation tool cannot take.
Resolving spoken self-corrections ("go left, no wait, right" → "go right") is the one thing a language model does that a regex cannot, and it's most of what people mean when a paid dictation app "reads their mind". It's off by default because it isn't free: spawning a model costs seconds on top of a transcription measured in low single digits.
polishCommand takes the transcript on stdin and prints the cleaned text. Anything of that
shape works:
murmurflow config set polishCommand "ollama run llama3.2" # stays local
murmurflow config set polishCommand "claude -p --model haiku" # sends text to AnthropicThis is the only way your words can leave your machine, and only because you sent them there. A local runner keeps everything local. A command pointing at a hosted API does not. Unset — the default — means the answer to "does this phone home" stays no.
A broken polish command degrades to the plain transcript. It never costs you the sentence.
murmurflow install set it up on this Mac; starts nothing (a listener already on updates)
murmurflow on dictation on, now and after every login
murmurflow off dictation off, now and after every restart, until `on`
murmurflow update the newest code; restarts dictation only if it is on
murmurflow listen run the daemon in this terminal instead (blocks)
murmurflow doctor what is missing, and the one command that fixes each thing
murmurflow keytest does this Mac actually see your trigger key?
murmurflow devices list microphones
murmurflow setup download both speech models (the big one, and the fast live one)
murmurflow config show or change settings
murmurflow toggle start/stop one recording (bind this to a macOS Shortcut)
murmurflow transcribe transcribe an audio file and print the text
murmurflow pause lend the trigger key to another program for a while
murmurflow resume take it back
murmurflow trigger print the trigger key this install is on, for another program to read
murmurflow uninstall `off`, and remove the MurmurFlow.app too
install, on, off, update and doctor mean the same here as in zyx and in agent-office's
office: install sets up and starts nothing, on and off both last across a restart, update never
switches on something that is off.
Every clip the daemon handles is logged — how long you held the key, how much audio actually
landed, the peak level, the transcribe time, how many characters came back and the app the paste
went to. Not what you said. That file is ~/.murmurflow/listen.log, murmurflow doctor prints
the path, and nothing rotates it — which is exactly why your sentences are not in it. Debugging one
bad dictation and want them? keepAudio keeps the clip and the transcript together for as long as
you leave it on, and deletes the clip when you turn it off.
config set refuses a setting it does not recognise and a value that cannot work — a trigger name
this machine cannot poll, a model path that is not there, a language you did not say you speak. All of those
used to be accepted and then fail silently, which is the same symptom as broken hardware.
update is the update. The listener does not run your checkout — uv tool install made
a copy of the package and launchd runs that — so a git pull alone changes a directory the running
program never reads, silently. murmurflow update therefore re-installs the package from wherever
it came from first (a local checkout, a git URL, PyPI), then re-executes itself out of the new copy
and restarts the listener if it is on. install and on do the same re-install first, so the old
habit still works. From a checkout, two commands, and only because the first one is git:
git pull && murmurflow updateAn update that cannot run never blocks the install: no uv, a source that has moved, a network
that is down — each prints a line and carries on with the copy already on the machine.
doctor and keytest exist because dictation fails in exactly four ways — the key isn't seen, the
microphone isn't heard, the model isn't found, the text isn't typed — and from the outside those are
indistinguishable. One run of each separates them.
- Audio is written to
~/.murmurflow/audio/and deleted the instant it's transcribed — before the optional polish call and before the paste, so a crash downstream can't leave your voice on disk. - Each pass copies the clip so far to a second file next to it and deletes that copy the moment the pass ends, pass or fail. The copy exists because the recorder has not finished writing the original's header yet and reading it means patching one — never the file ffmpeg is still appending to. It lives for the length of one decode and it is inside the same directory the clip is, so it is covered by the same deletion and the same 10-minute ceiling.
- The recorder bounds its own life at 10 minutes, so a daemon killed mid-clip can't leave the microphone hot. (This is not hypothetical: it was found in development as three orphaned recorders, 4.5 hours each, 1.4 GB of audio, microphone open the whole time.)
- The transcript is never inspected, filtered, or written down. It goes to your clipboard, then
your cursor, and your previous clipboard contents are put back — all of them, not just text. Copy
a screenshot while a dictation is in flight and the screenshot is still on your clipboard
afterwards. The one exception is
keepAudio, which you turn on yourself to debug a bad dictation. It keeps that clip until you turn it off — switching it off deletes it. The transcript it also writes into the daemon log is a line in a log, and turning the setting off does not go back and remove it; delete the log yourself if you want it gone. - If a paste cannot land — Secure Input is on, or Accessibility was never granted — your words go to the clipboard so you can paste them by hand, and that replaces what was on it. The daemon says so rather than leaving you to find out: there is no way to put the old contents back once the recovery paste is the only copy of what you just said.
- Nothing in this repo makes a network request except
murmurflow setup, which downloads the model from Hugging Face.
uv run pytest # hermetic: no microphone, no whisper, no network
uv run ruff check .The hardware paths (start/finish/inject) have no unit tests on purpose — a mock of CoreAudio
only ever tests the mock. murmurflow doctor and murmurflow keytest are how those are exercised.
MIT. See LICENSE.
MurmurFlow is one of the tools ZyxWorks, a product studio and forward deployed engineering practice, built for itself and gave away. It was extracted from Zyx, the OS the studio runs on, which is also why a Mac running both types every sentence twice until you turn one off.
The other one is Agent Office: several coding agents in one tmux window, each in its own git worktree, and the one that has stopped and is waiting on you says so on its border.
Product: page · all our tools · issues
Studio: what we do for companies · Zyx · GitHub
Legal: MIT licence · privacy · imprint
MurmurFlow is not affiliated with, endorsed by, or connected to Wispr AI, Inc. or any other dictation product. Product names mentioned are the trademarks of their respective owners.