Push-to-talk dictation for macOS.
Hold a key, speak, release — the text lands in whatever app you're in,
including your terminal.
The HUD waveform — live microphone level while recording, travelling pulse while processing.
- What it does
- Why it exists
- Requirements
- Installation
- Building a DMG to share
- Getting your API keys
- First run and permissions
- Everyday use
- Features in depth
- Configuration reference
- Command-line tools
- How it works
- Latency
- Privacy and security
- Troubleshooting
- FAQ
- Contributing
- Support
- License
Hold Right Option
↓ record microphone → 16 kHz mono WAV, in memory
↓ ElevenLabs Scribe → raw transcript
↓ LLM pass → cleaned up, aware of the destination app
↓ clipboard + ⌘V → lands wherever your cursor is
There is no per-app integration and no plugin for anything. VoiceMe puts the text on the clipboard and synthesises a ⌘V keystroke into the frontmost application. To Gmail, Slack, Notion, Xcode, or your terminal, this is an ordinary paste — which is exactly why it works everywhere without knowing anything about any of them.
Bring your own keys. VoiceMe ships with no credentials, no backend, and no accounts. Nothing is stored on a server, phoned home, or proxied. Your audio goes from your Mac directly to the API you configured, and nowhere else.
No dependencies. Pure Swift against system frameworks. No package manager pulls anything at build time, and there is nothing in the binary you did not compile yourself.
Most dictation tools are tuned for English and fall apart in a terminal. VoiceMe was built around two problems the others handle badly.
Mixed-language speech. Indonesian and English in the same sentence — "saya mau deploy edge function-nya" — stays exactly as spoken. Not translated into one language or the other, technical terms intact. Language detection is left on automatic precisely so code-switching survives.
Terminals and coding agents. A transcript that ends in a newline is an Enter keystroke: your prompt is submitted before you can read it. VoiceMe strips trailing newlines, and tells the cleanup pass to emit a single paragraph with file paths, flags, and identifiers preserved character for character. It sounds like a footnote. It is the difference between usable and infuriating in Claude Code.
| macOS | 14 (Sonoma) or later |
| Toolchain | Xcode command line tools — xcode-select --install. Full Xcode is not required |
| Hardware | Apple Silicon or Intel. Any microphone macOS recognises |
| Accounts | An ElevenLabs API key (required). An LLM key for the cleanup pass (optional) |
git clone https://github.com/mangerik/voiceme.git
cd voiceme
./build.sh
open VoiceMe.appbuild.sh compiles with SwiftPM, renders the app icon from code, assembles
the .app bundle by hand, and code-signs it. It takes a few seconds.
macOS ties Accessibility approval to an app's code signature, not its name or location. Ad-hoc signing produces a new signature on every build, so each rebuild looks like a different app and your permission silently stops applying — the checkbox stays ticked while nothing works.
build.sh therefore signs with the first code-signing certificate in your
keychain, which keeps the identity stable across rebuilds. To choose a
specific one:
security find-identity -v -p codesigning # list what you have
VOICEME_SIGN_ID="Apple Development: You (XXXXXXXXXX)" ./build.shWith no certificate at all it falls back to ad-hoc signing and still works — you will just have to re-grant Accessibility after each build.
./release.sh # → dist/VoiceMe-0.1.0.dmgrelease.sh differs from build.sh in three ways that only matter once the
app leaves your machine:
- Universal binary. It compiles arm64 and x86_64 separately and joins them
with
lipo, so the app runs on Intel Macs too. (swift buildcan target both in one pass, but only with full Xcode installed.) - Hardened runtime. Required for notarisation. It also cuts off microphone
access unless
Resources/VoiceMe.entitlementsis signed in alongside it — which the script does. - Notarisation, when credentials are available.
A DMG built on a machine with no Developer ID is fine for your own testing but macOS will refuse it elsewhere. Two things are needed:
A Developer ID Application certificate. Requires an Apple Developer Program membership ($99/year). An Apple Development certificate — the kind Xcode creates for local work — is only valid on machines registered to its provisioning profile; everywhere else the app is rejected outright.
Notarisation. Apple scans the upload and issues a ticket. Store the credentials once:
xcrun notarytool store-credentials voiceme \
--apple-id you@example.com --team-id TEAMID --password app-specific-passwordThen every release picks them up:
VOICEME_NOTARY_PROFILE=voiceme ./release.shThe script staples the ticket to the DMG, so the recipient's Mac can verify it without being online.
Without both, whoever you send it to has to right-click → Open, or approve the app under System Settings → Privacy & Security. Recent macOS versions have been tightening that escape hatch.
Speech-to-text. Sign up at elevenlabs.io and create an API key in your account settings.
A restricted key scoped to speech-to-text is enough — VoiceMe never calls
anything else. Note that such a key will fail against endpoints like
/v1/user, which is why --selftest deliberately exercises the
transcription endpoint itself rather than a cheaper one.
Scribe is billed per minute of audio. Check current pricing on their site before heavy use.
The cleanup and learning features call an LLM over the OpenAI Responses
API shape. The default configuration points at
BytePlus Ark, but arkBaseUrl is a plain
setting — any endpoint accepting the same request shape should work.
Leave arkApiKey empty and turn polishEnabled off if you would rather run
transcription only. Everything else keeps working.
VoiceMe needs two macOS permissions and asks for both on first launch.
| Permission | What it's for | If denied |
|---|---|---|
| Microphone | recording your voice | no audio is captured |
| Accessibility | listening for the hotkey globally, and sending ⌘V to other apps | the hotkey does nothing at all |
Grant Accessibility in System Settings → Privacy & Security → Accessibility by ticking VoiceMe. If the app was already running, quit and reopen it.
Then add your key:
open ~/.config/voiceme/config.json…or open Pengaturan… (⌘,) from the menu bar and paste it there.
Verify both APIs actually respond before assuming anything is broken:
./VoiceMe.app/Contents/MacOS/VoiceMe --selftestThe log at ~/.config/voiceme/voiceme.log records accessibility trusted:
on every launch. That line is the ground truth — a ticked checkbox in System
Settings is not.
Hold Right Option, speak, release. That is the whole interface.
While you hold it, a small panel appears with a waveform that follows your voice. On release it switches to a travelling pulse, then the text is pasted where your cursor is.
The menu bar icon (🎤) holds the switches you toggle most:
| Item | |
|---|---|
| Aktif | master on/off without quitting |
| Rapikan dengan AI | the cleanup pass |
| Kirim otomatis setelah bicara | auto-enter — safe in chat, risky in a terminal |
| Jalankan saat login | start VoiceMe whenever the Mac boots |
Pengaturan… (⌘,) |
full settings window |
| Buka config.json / Muat ulang config | for settings not exposed in the UI |
| Buka log | when something needs diagnosing |
Presses shorter than minHoldSeconds (0.35 s by default) are discarded as
accidental, so brushing the key costs nothing.
Raw speech-to-text gives you every "um" and false start. The cleanup pass sends the transcript to an LLM that removes filler, fixes punctuation and capitalisation, and repairs obviously misheard technical terms — while keeping your language, your meaning, and every name and number.
It also knows which app the text is headed for and adapts: complete sentences for Mail, short and casual for Slack, a precise technical instruction for a terminal. Built-in hints cover common terminals, editors, mail clients, chat apps, and note apps.
Override or extend them with appHints in config.json:
"appHints": {
"Ghostty": "a terminal running Claude Code. Emit a precise technical instruction. Keep file paths and flags exact.",
"Slack": "a work chat. Casual but clear, two sentences max.",
"Mail": "a client-facing email. Formal, complete sentences."
}Keys match as case-insensitive substrings of the application name.
The cleanup pass can never cost you your words. Every failure — timeout, bad key, malformed response — falls back silently to the raw transcript. A slow API costs you polish, never text.
If all you want is filler removal, scribe_v2 can do it during transcription
for free via noVerbatim, with no second API call. Turn that on and
polishEnabled off, and the whole pipeline drops to about a second.
Turn it on in Settings → Belajar, choose the language you are learning, and carry on working. Every sentence you dictate is reviewed: the corrected version is what gets pasted, and the panel shows what you got wrong and why.
The point is that you never schedule practice. You learn while writing the email you were going to write anyway.
Explanations come back in Indonesian, since that is the learner's own
language — change the why instruction in
TutorClient.swift for a different one.
Ten target languages ship in TutorClient.languages; adding more is one line.
It cannot assess pronunciation. Scribe returns text, not phonemes. If you mispronounce a word and it is still recognised correctly, VoiceMe has no way to know. Grammar, word choice, and phrasing are what get corrected. Anything claiming otherwise from this stack would be guessing.
Speech-to-text does not sanitise your grammar — this was measured, not
assumed. Dictating "This is test for the learning mode" produced exactly
that in the raw transcript, missing article included, and the tutor caught
it. scribe_v2 is verbatim by default. The panel always shows your original
sentence so you can verify this for yourself at any time.
One consequence worth knowing: to make a sentence natural, the tutor may add words you did not say. Good for practice, less so for a contract. Turn learning mode off for those and let the ordinary cleanup pass run — it is far more conservative.
With Kirim otomatis setelah bicara on, VoiceMe presses Return after pasting. Speak, and the message is sent. Natural in Slack, WhatsApp, or a search field.
Return is sent as a separate keystroke, not as a newline appended to the
text. Applications disagree about what a trailing newline means; a keypress
always means send. This also lets stripTrailingNewline keep doing its job,
so the two cannot combine into a double submit.
In a terminal this removes your last chance to read. The command runs exactly as transcribed, misheard words included. The toggle is in the menu bar as well as in Settings precisely because the right value depends on which app you just switched to.
keyterms — names, product names, technical jargon. Sent to Scribe so it
is more likely to hear them correctly, and passed to the cleanup pass as the
canonical spelling. Maximum 50 characters and 5 words per entry.
shortcuts — plain find-and-replace applied last, just before pasting.
For things a model will never get right on its own:
"shortcuts": {
"my work email": "me@company.com",
"the main repo": "github.com/mangerik/voiceme"
}Both are editable in Settings → Kamus, one per line.
Settings live in ~/.config/voiceme/config.json, written with mode 0600.
Most of it is editable from Pengaturan… (⌘,); the file is there for the
rest. See config.example.json for a blank template.
Changes made in the window save as you type. If you edit the file by hand, use Muat ulang config in the menu bar.
| Field | Default | Notes |
|---|---|---|
elevenLabsApiKey |
"" |
Required. A speech-to-text-scoped key is enough |
sttModel |
scribe_v2 |
or scribe_v1, the older model. v2 is better in essentially every way |
languageCode |
null |
null = auto-detect. Leave it for mixed-language speech |
keyterms |
[] |
Custom vocabulary. Max 50 chars / 5 words each |
noVerbatim |
false |
Let Scribe drop fillers itself. scribe_v2 only; forced off in learning mode |
| Field | Default | Notes |
|---|---|---|
polishEnabled |
true |
Turn off for raw transcripts and roughly a second less latency |
arkApiKey |
"" |
Leave empty to disable the pass entirely |
arkModel |
dola-seed-2-1-turbo-260628 |
|
arkBaseUrl |
BytePlus Ark responses endpoint | Any Responses-API-compatible endpoint |
arkThinking |
disabled |
enabled is marginally better and roughly 5× slower |
polishTimeout |
10 s |
On timeout the raw transcript is used instead |
appHints |
{} |
Per-app instructions, overriding the built-ins |
| Field | Default | Notes |
|---|---|---|
learningMode |
false |
Replaces the cleanup pass while active |
learningLanguage |
en |
See TutorClient.languages |
| Field | Default | Notes |
|---|---|---|
hotkey |
rightOption |
leftOption, rightCommand, rightControl, fn, f13 |
minHoldSeconds |
0.35 |
Shorter presses are treated as accidental |
maxRecordSeconds |
120 |
Safety valve for a stuck key |
| Field | Default | Notes |
|---|---|---|
shortcuts |
{} |
Replacements applied just before pasting |
stripTrailingNewline |
true |
Keep this on if you use a terminal |
autoEnter |
false |
Press Return after pasting |
playSounds |
true |
Tink on start, Pop on finish |
donationURL |
"" |
Override the project's support link when forking |
Launch at login is deliberately not here. macOS owns that state through
SMAppService — the same list you see under System Settings › General › Login
Items. Mirroring it into config.json would create a second source of truth that
can disagree with the first, and a checkbox that lies. If macOS is holding the
registration back (you turned VoiceMe off in Login Items at some point), no
amount of code can turn it back on — only you can, from System Settings.
The registration follows wherever VoiceMe.app sat when you enabled it. Move
the app and you need to toggle the switch off and on again.
The binary inside the bundle doubles as a diagnostic tool.
BIN=./VoiceMe.app/Contents/MacOS/VoiceMe| Command | What it does |
|---|---|
$BIN --selftest |
Sends a test tone to Scribe and a sample sentence to the cleanup pass. Confirms keys, model IDs, and keyterms encoding are all accepted. Exits non-zero on failure |
$BIN --tutor "sentence" |
Runs the language tutor on text, no microphone needed. The fastest way to iterate on the correction prompt |
$BIN --render-lesson out.png |
Renders the correction panel to an image, so layout can be checked without waiting for it to appear |
$BIN --render-waveform out.png |
Renders the HUD waveform in both states |
$BIN --render-icon out.iconset |
Regenerates the app icon at all ten required sizes |
The same self-test is available as a button in Settings → Transkripsi, sharing one code path so the two can never disagree about what passing means.
Sources/VoiceMe/
main.swift entry point, CLI flag dispatch
AppDelegate.swift menu bar, permissions, pipeline orchestration
Config.swift settings model, lenient decoder, atomic 0600 writes
SettingsStore.swift single source of truth at runtime, debounced saves
SettingsView.swift SwiftUI settings form
SettingsWindow.swift the only window allowed to take focus
HotKeyMonitor.swift global push-to-talk via NSEvent monitors
AudioRecorder.swift AVAudioEngine → 16 kHz mono WAV, plus RMS levels
WAV.swift PCM container, shared by recorder and self-test
ScribeClient.swift ElevenLabs speech-to-text
ArkClient.swift shared HTTP for LLM calls
PolishClient.swift cleanup pass, falls back to the raw transcript
TutorClient.swift language corrections, structured output
TextInjector.swift clipboard save → paste → restore, synthetic keys
HUD.swift non-activating status panel
WaveformView.swift audio-driven bars
LessonView.swift correction panel contents
IconRenderer.swift app icon, drawn from code
Permissions.swift Accessibility and Microphone
LaunchAtLogin.swift SMAppService registration, state owned by macOS
Log.swift file logging
Four invariants hold the design together. Break any of them and VoiceMe misbehaves in ways that are hard to trace:
The HUD must never take focus. It is an NSPanel with
.nonactivatingPanel, and the app runs as .accessory. If either changes,
the frontmost app stops being frontmost and the paste lands in the wrong
window.
The cleanup pass must never block output. Every failure path returns the raw transcript.
Trailing newlines are dangerous. In a terminal a newline is Enter. When the app wants to submit, it sends a Return keystroke instead — explicit, and never mistaken for part of your text.
The signature affects permissions. See About code signing.
The clipboard is saved before pasting and restored roughly a third of a second later, so dictating does not destroy whatever you had copied.
The waveform is drawn in
WaveformView.swift, not Lottie — and
that is a constraint, not a preference. A Lottie file replays a pre-rendered
timeline and has no way to receive microphone amplitude. What makes the HUD
feel alive is that each bar's height genuinely comes from the RMS of the
audio buffer, mapped to a decibel scale (hearing is logarithmic; a linear
mapping makes normal speech nearly invisible) and smoothed asymmetrically —
fast attack so it responds, slow decay so it does not flicker between
syllables.
The panel is pinned to a dark appearance because the HUD material is always
dark, including in light mode, where labelColor would otherwise resolve to
black on a dark background.
Measured on Apple Silicon, from releasing the key to text appearing:
| Stage | Typical |
|---|---|
| Scribe transcription | 0.8 – 1.7 s |
| Cleanup pass | 0.4 – 1.5 s |
| Learning-mode review | ~2.3 s |
| Total, dictation | ~2 – 4 s |
| Total, learning mode | ~4 s |
To go faster:
- Turn Rapikan dengan AI off → about one second, straight from Scribe. Punctuation is already decent; filler words survive.
- Or turn
noVerbatimon and leave cleanup off → about a second, with fillers removed by Scribe at no extra cost. - Keep
arkThinkingondisabled. Enabling reasoning took the cleanup pass from roughly 3 s to 14 s in testing, for a marginal quality gain.
- No backend. VoiceMe has no server. Audio goes from your Mac to the API you configured, and nowhere else.
- No bundled credentials. Nothing ships with the app; you supply your own keys.
- No telemetry. Nothing is collected, counted, or reported.
- Audio is never written to disk. Recording lives in memory and is discarded after transcription.
- Config lives at
~/.config/voiceme/config.json, mode0600— outside the repository, so it cannot be committed by accident. - Transcripts are not logged, with one exception: learning mode records
what Scribe heard, so you can verify the transcript is faithful to what you
actually said. The log is local, at
~/.config/voiceme/voiceme.log, and you can delete it at any time.
Accessibility permission is powerful — it allows synthesising keystrokes into
any application. Everything VoiceMe does with it lives in
TextInjector.swift, which is under a
hundred lines. Read it.
tail -f ~/.config/voiceme/voiceme.log| Symptom | Cause and fix |
|---|---|
| Hotkey does nothing | Accessibility. Check the accessibility trusted: line at the top of the log — it is authoritative, the checkbox is not |
| Accessibility keeps re-prompting although it is enabled | The binary's signature changed. Use a keychain certificate (see signing), then tccutil reset Accessibility com.socai.voiceme and grant once more |
| "Tidak ada suara terekam" | Microphone permission, or the wrong input device in Sound Settings |
| Text lands in the wrong window | You switched apps while it was still processing. The destination is captured when you press the key, not when the text is ready |
Scribe error 401 |
Wrong key, or a restricted key without speech-to-text scope |
Scribe error 422 |
A keyterms entry exceeds 50 characters or 5 words |
| Cleanup pass appears to do nothing | By design it fails silently to the raw transcript. The log says why |
| Text pasted but nothing was sent | autoEnter is off — that is the default |
| A message sent before you could read it | autoEnter is on. Turn it off from the menu bar |
| Clipboard contents disappeared | Should not happen; the clipboard is saved and restored. Please open an issue with the log |
Does it work offline? No. Transcription is an API call. A local Whisper backend would be a very welcome contribution — it would also make the whole thing free to run.
Can I use a different speech-to-text provider?
Not yet without editing code.
ScribeClient.swift is small and
self-contained; a pull request generalising it would be welcome.
Can I use a different LLM?
Yes, if it accepts the Responses API request shape — set arkBaseUrl,
arkModel, and arkApiKey.
Why is the interface in Indonesian? It was built for its author first. The code, the model prompts, and this README are in English; localising the UI would be a straightforward contribution.
Does it support languages other than Indonesian and English? Yes. Scribe handles 90+ languages and detection is automatic by default. Learning mode ships with ten target languages and adding more is one line.
Why does it need Accessibility just to type? macOS treats synthesising keystrokes into another application as a privileged operation, and it is the same permission that allows listening for a hotkey while another app is focused.
Can I bind it to Caps Lock or a mouse button?
Not currently. Supported keys are listed under Input; adding more
means extending PushToTalkKey.
Bug reports, hardware I cannot test on, and prompt improvements are all genuinely useful. See CONTRIBUTING.md — it documents the invariants above and how to verify a change before opening a pull request.
VoiceMe is free software and will stay that way. GPL-3.0 guarantees that for you and for anyone who forks it.
If it saves you time, chip in — entirely optional, and it changes nothing about the software. Bug reports and prompt improvements are worth more than a small donation anyway.
GPL-3.0. You may use, modify, and even sell it, but derivative works must remain open source under the same licence.
