Drive macrowhisper automations with VoiceInk dictations, without modifying either app.
macrowhisper turns a dictation into an action: paste it, open a URL, run a Shortcut, a shell
script, or AppleScript, with contextual triggers and text templating. It gets those dictations
by watching Superwhisper's recordings folder. VoiceInk writes no such folder, so the two cannot
talk. macrovoice is the missing piece.
VoiceInk Mode (Output = Custom Command)
-> macrovoice.sh --mode <name>
-> ~/macrovoice/recordings/<id>/meta.json (atomic directory rename)
-> stock macrowhisper: validate -> match triggers -> run action
Proven end to end against live VoiceInk 2.1 and macrowhisper 2.1.1: dictate, and macrowhisper
pastes with smart casing and spacing, voice triggers fire, triggerModes matches. Neither app
is patched and neither knows the other exists.
- Every macrowhisper action type works: paste, URL, Shortcut, shell, AppleScript
- Voice, app, URL and mode triggers all fire normally
- Zero changes to VoiceInk or macrowhisper; both run stock
- Survives the four watcher behaviours that silently drop dictations (see How it works)
- Never loses a transcript, even when publishing fails or the process is killed at the deadline
- Logs transcript length, not content, so the log is not a record of everything you say
- Drives the whole pipeline from a pipe too, so actions are testable without a microphone
- No dependencies beyond system Python 3
| macOS | Tested on Darwin 25.6 |
| VoiceInk | 2.0 or later, for the Custom Command output mode |
| macrowhisper | 2.1.1 or later |
| Python 3 | System Python is fine, no packages needed |
1. Install macrowhisper and create a watch directory
brew install ognistik/formulae/macrowhisper
mkdir -p ~/macrovoice/recordingsDo not point this at ~/superwhisper. If you also run Superwhisper, the bridge would interleave
synthetic recordings with real ones.
2. Configure macrowhisper
cp macrowhisper.sample.json ~/.config/macrowhisper/macrowhisper.json
macrowhisper --start-service
sleep 8 && macrowhisper --statusThe sleep matters. Folders that already exist when the recordings watcher arms are marked
processed and dropped, so publishing during the startup window looks like nothing happening.
Switch actions with the CLI, not by editing the config while the daemon runs:
macrowhisper --action markerLog # switch
macrowhisper --get-action # verify it tookA file edit landing just after macrowhisper's own internal write is swallowed
(Suppressed config reload after internal write), and autoUpdateConfig then rewrites the file
from memory, erasing your change.
3. Verify the macrowhisper half, before involving VoiceInk
macrowhisper --action markerLog
VOICEINK_TRANSCRIPT='hello world' ./macrovoice.sh --watch ~/macrovoice
sleep 2 && cat ~/macrovoice/fired.logA line appears. If it does not, fix that before going further.
4. Check what VoiceInk actually sends
In VoiceInk, create a Mode with Output = Custom Command pointed at probe.sh, give it a
keyboard shortcut or set it as default (see Picking the Mode),
dictate a few times, then read ~/macrovoice/probe.log. Confirm one INVOCATION block per
dictation and that nothing was pasted into the focused app.
5. Go live
Point the Mode at macrovoice.sh, switch back with macrowhisper --action autoPaste, grant
macrowhisper Accessibility permission, focus a text field, and dictate. Say "ask google best
pizza" or "google best pizza" to try the voice trigger from the sample config, which ships both
phrasings as "triggerVoice": "ask google|google". The sample also ships ask youtube,
ask perplexity and ask openai (which also answers to "ask chatgpt"), each with the same
both-phrasings treatment.
A dictation that matches a voice trigger runs that action and nothing else: the trigger action
replaces your default action rather than running alongside it, so "ask google ..." opens a browser
and pastes nothing. Only a dictation that matches no trigger falls through to autoPaste.
Voice triggers are prefix-anchored: macrowhisper builds "^(?i)" + escaped pattern
(Utils/TriggerEvaluator.swift:205), so the trigger must be at the start of the dictation.
| separates alternatives and each one is anchored independently, which is why the sample lists
"ask google" as well as the bare word. "Google best pizza" and "Ask Google best pizza" both
match; "Can you Google the best pizza" does not, and silently falls through to your default
action.
Matching is a raw prefix, not a word boundary, so "googled the answer" also matches and searches
for the leftover "D the answer". Use a ==regex== trigger if that matters to you, remembering
that raw regex disables the automatic prefix stripping.
VoiceInk does not tell the command which Mode fired, so bake the name into each Mode's command
if you want triggerModes to work:
/abs/path/to/macrovoice.sh --mode email
The watch directory used to be called ~/mw-bridge, from before the tool had a name. It is now
~/macrovoice. Nothing breaks if you do nothing. If ~/mw-bridge exists and ~/macrovoice
does not, macrovoice keeps using ~/mw-bridge, exactly as before, and doctor prints one
warning telling you the name has changed. Upgrading cannot silently redirect your dictations
into a directory macrowhisper is not watching.
Migrating is optional, and takes four commands:
macrowhisper --stop-service
mv ~/mw-bridge ~/macrovoice
# then set defaults.watch to "~/macrovoice" in ~/.config/macrowhisper/macrowhisper.json
macrowhisper --start-service
./macrovoice.sh doctor --check # confirm before you dictate againDo not dictate between the mv and the --start-service. Edit the config file only while the
daemon is stopped: an edit landing while it runs can be swallowed and then overwritten from
memory.
Both directories existing is the one state to avoid. macrovoice will use ~/macrovoice while
macrowhisper may still be watching the old copy, and every dictation lands where nothing is
looking. doctor catches it two ways: mw.watchmatch fails and names both paths, and
bridge.legacywatch names the leftover directory. Run doctor --check after migrating, before
your next dictation.
The environment variable MW_BRIDGE_WATCH still works and is not going away in this release.
MACROVOICE_WATCH is the current name; if both are set, the new one wins.
A Custom Command Mode is inert unless it is the default or has its own keyboard shortcut.
VoiceInk resolves the Mode per dictation in ActiveWindowService.beginApplyingConfiguration
(Modes/ActiveWindowService.swift:19-46):
- A Mode-specific shortcut passes that Mode's id and wins outright.
- The generic hotkey passes no id and resolves
getConfigurationForApp(bundleId) ?? getDefaultConfiguration(): an app rule if one matches, otherwise the Mode marked Set as default. - With no default at all, it falls back to list order (
Modes/ModeConfig.swift:420-428).
So a Mode that is saved but neither default nor shortcut-bound never runs. Every dictation goes through your normal Mode, the text pastes as usual, and your command is never called. That looks exactly like "VoiceInk does not suppress the paste, so this cannot work," and it is not.
Verify what is actually stored:
python3 -c "
import subprocess, plistlib, json, io
p = subprocess.run(['defaults','export','com.prakashjoshipax.VoiceInk','-'], capture_output=True)
for c in json.loads(plistlib.load(io.BytesIO(p.stdout))['modeConfigurationsV2']):
print(f\"{c['name']:16} outputMode={c['outputMode']:14} isDefault={c['isDefault']}\")
"Do not diagnose from activeConfigurationId. It looks like a sticky override and is not: it is
rewritten at the start of every dictation, so it only records what the last one used.
The recommended layout: leave your everyday Mode as default, and give the bridge Mode its own shortcut. Normal dictation then pastes as usual and never depends on macrowhisper being alive; the shortcut routes through the bridge on demand.
The sample config sets this for you. If you write your own, do not skip it.
macrowhisper defaults simEsc to true and, before pasting, posts a literal Escape keypress
to the system-wide HID event tap (Utils/Accessibility.swift:477-494, simulateKeyDown key 53).
Under Superwhisper that ESC dismisses Superwhisper's own recording window. Under this bridge
there is no such window, so the Escape lands in whatever app you are typing into.
Measured: dictating into a ProtonMail compose window closed the draft, and the paste then had nowhere to land. Any app where ESC means cancel, close, or discard is exposed, and you lose work with no error.
What makes it easy to misdiagnose is that the damage is app-specific. The same dictation pastes perfectly into a browser address bar or a terminal, where Escape is harmless, so it reads as a paste bug in one app rather than a global setting doing collateral damage.
./macrovoice.sh doctor --checkTwenty-seven checks across both apps, reported in the order you hit them. It is read-only: it never creates a directory, edits a config, or writes to VoiceInk.
One of them, bridge.legacywatch, is a warning rather than a problem: it tells you the watch
directory has been renamed to ~/macrovoice without pretending your working setup is broken. See
Upgrading from ~/mw-bridge.
Six of them inspect the VoiceInk half, which is where the traps that look most like "the bridge
is broken" actually live. The important one is vi.reachable: a Custom Command Mode that is
neither the default nor shortcut-bound is never selected, so your command is simply never
called and every dictation pastes as usual. That reads exactly like the bridge premise being
wrong, and is not. vi.command catches a Mode still pointing at a folder you have since moved
or renamed, and vi.checkout warns when VoiceInk is running a different copy of macrovoice
than the one you are editing.
Because VoiceInk loads its Modes once at launch and never re-reads them, a reading taken while it is running can lag what its window shows. Every VoiceInk repair hint therefore ends by telling you to quit VoiceInk and re-run, and the report says so when VoiceInk is up.
Three outcomes, and the third one matters. ok and PROBLEM mean what you expect. unknown
means a check could not be run, and it names what blocked it: if macrowhisper is not running,
simEsc is not fine and not broken, it is unknowable. Fix the problem at the top and re-run.
That is also why a bare machine reports one real problem and a run of unknowns instead of a wall
of alarms: everything downstream of the one thing that is actually broken has nothing to inspect.
Exit codes: 0 healthy, 1 a fatal problem remains, 2 a fatal check could not be determined.
| Flag | Default | Description |
|---|---|---|
--mode <name> |
none | Written as modeName, feeding macrowhisper's triggerModes |
--watch <path> |
$MACROVOICE_WATCH, else ~/macrovoice |
macrowhisper's watch root. See Upgrading |
--gap <seconds> |
1.0 |
Minimum spacing between publishes |
--drain-only |
off | Publish anything left in the spool and exit |
--no-liveness-check |
off | Publish even when macrowhisper is provably not running. See below |
--log-transcript |
off | Log transcript text instead of just its length |
macrovoice takes the transcript from VOICEINK_TRANSCRIPT, and falls back to stdin when that
variable is absent. So the entire pipeline runs from a pipe, with no dictation and no VoiceInk:
echo "ask google best pizza in madrid" | ./macrovoice.shThat publishes a real meta.json, and macrowhisper matches triggers and runs its action exactly as
it would for a spoken dictation. It is the cheapest way to test an action you are writing, and it
also makes the bridge scriptable by anything that can write to a pipe.
Point it somewhere harmless while you experiment, so you are not firing actions into your real setup:
mkdir -p /tmp/mv-scratch
echo "hello world" | ./macrovoice.sh --watch /tmp/mv-scratch
cat /tmp/mv-scratch/recordings/*/meta.jsonPair it with macrowhisper's own dry run, which resolves the full action-selection pipeline against
a hand-written meta.json and skips moveTo, so nothing is consumed:
macrowhisper --run-auto --meta /path/to/meta.jsonThree things worth knowing:
- The text is passed through byte for byte, so
echocontributes a trailing newline. Useprintf '%s' "..."if that matters, though macrowhisper'ssmartSpacinghandles stray edge whitespace at insertion time anyway. - Empty or whitespace-only input publishes nothing and exits 0, logging
skipped. - The stdin read blocks by design when
VOICEINK_TRANSCRIPTis absent, because in that case stdin genuinely is the only source of the words. If you callmacrovoicefrom something that opens a pipe and never closes it, pass the transcript in the environment variable instead.
macrowhisper's watcher has four behaviours a naive bridge trips over. Each is a silent
failure: nothing errors, the dictation just disappears. Line references are to
Watcher/RecordingsFolderWatcher.swift in macrowhisper 2.1.1.
| Behaviour | Evidence | How macrovoice handles it |
|---|---|---|
A folder without meta.json inside it takes a slow path, then is cancelled after 17s for having no .wav |
:38, :457-462, :1928-1935 |
Builds the folder in a staging dir and renames the whole directory in, so it is never seen half-built |
| Two folders appearing in one filesystem event means none of them run | :327-345 |
Serializes publishing behind an flock with a minimum gap |
| A folder whose name sorts below the newest one is discarded as cloud-sync replay | :350 |
Mints the published name at publish time, always above the current maximum |
| Folders that already exist when the watcher arms are marked processed at startup | startup path | Checks that macrowhisper is actually listening before publishing, and keeps the transcript spooled if it is not. Still wait a few seconds after starting the daemon |
macrovoice checks that macrowhisper is actually running before it publishes, and if it is
provably not, keeps your transcript in the spool instead. This is not politeness about ordering.
Publishing into an unwatched directory destroys the dictation: the folder sits there, and when
macrowhisper next starts, its watcher marks every folder that already exists as processed and runs
none of them. The restart that should have delivered your words is what throws them away.
The rule is deliberately one-sided: it defers only on macrowhisper's own
macrowhisper is not running. sentence. Anything else publishes, including a timeout, a
macrowhisper that is not on PATH, and output it does not recognise. --status exits 0 whether or
not a daemon is listening, so that sentence is the only definitive signal, and its absence is not
proof of life. A check that hesitated whenever it was unsure could quietly stop delivering on a
working setup, which is worse than the loss it prevents.
Deferred transcripts wait in .spool/, which is outside recordings/ and so cannot be touched by
that same startup sweep. The next run publishes them, and --drain-only forces it. The log says
what happened and how to fix it rather than going quiet.
In practice the window is small: the launchd agent restarts macrowhisper within seconds of a crash,
so only a clean stop (--stop-service, an upgrade, a logout) leaves it down. Pass
--no-liveness-check to skip the check. One caveat worth knowing: the check asks whether a
macrowhisper is listening, not whether the one watching your directory is. With the normal
one-daemon setup those are the same question.
Plus one from VoiceInk: it suppresses its own paste and then kills the command at 10 seconds
(TranscriptionDelivery.swift:43-46, :115), so the transcript exists nowhere else.
macrovoice spools first, unconditionally, and only then publishes. It always exits 0, because
a non-zero exit shows the user an error without recovering their words.
Measured on a real install: three folders created in a tight loop produced one action and two losses. Five concurrent dictations produced five folders but only four actions, until the naming fix; after it, five of five. At human speaking cadence, five dictations in ten seconds all delivered, and a 633-character dictation arrived intact.
535 tests, 11 skipped: 274 on the delivery path, 261 for doctor. Every number in this section is
derived from unittest discovery by tests/test_readme.py, because all of them had drifted at
least once, one of them by 89. Total branch coverage is 99%
(99.36%), measured across the whole package with subprocess tracing. That last part matters: a
naive run reports cli.py at 0%, which is wrong, because its tests drive it as a real subprocess
that coverage cannot see without COVERAGE_PROCESS_START and a sitecustomize.py. Every
delivery-path module (transcript.py, meta.py, publisher.py, cli.py) is at 100%, and so is
most of doctor: model.py, registry.py, runner.py, report.py, and process.py. What is
left uncovered: __main__.py's entry-point guard, two loop branches in the bridge adapter, and
four spots in the macrowhisper adapter, none reachable without a real daemon. CI runs the suite on
macOS across Python 3.9, 3.12 and 3.13. The 3.9 entry is deliberate: macrovoice.sh execs
/usr/bin/env python3, and on a stock Mac that is the system Python.
| File | Tests | Covers |
|---|---|---|
tests/test_doctor_*.py (9 files) |
261 | doctor's checks, adapters, runner, report and status parser, exercised without a real macrowhisper |
tests/test_publisher.py |
57 | Staging, spool, drain lock, burst spacing, atomic renames, name monotonicity, cross-process collisions, and a future-dated .last-publish no longer stalling delivery |
tests/test_cli.py |
42 | The CLI driven through real subprocesses, including the exit-code policy, the open-stdin regression, and which watch root a bare invocation resolves to |
tests/test_integration_safety.py |
26 | The integration suite's own guard against hijacking your macrowhisper |
tests/test_harness_port.py |
24 | That the oracle still matches macrowhisper's validation gate, branch for branch |
tests/test_meta.py |
23 | A 31-entry escaping matrix and the meta.json schema contract |
tests/test_transcript.py |
22 | Env and stdin resolution, the empty-input policy, and whether stdin needs reading at all |
tests/test_watch.py |
23 | Watch-root resolution against a real temporary home, including that an unmigrated ~/mw-bridge keeps working, plus the two .gitignore traps: never ignore the package directory, never ignore .claude/ wholesale |
tests/test_voiceink_invocation.py |
15 | The .sh wrappers through /bin/zsh -lc, exactly as VoiceInk calls them, and that probe.sh resolves the watch root the same way Python does |
tests/test_listener.py |
14 | The liveness probe, including that it only defers on macrowhisper's own "not running" sentence |
tests/test_integration_macrowhisper.py |
11 | Opt-in; drives a real macrowhisper daemon. These 11 are the whole of what a default run skips |
tests/test_sample_config.py |
11 | The shipped macrowhisper.sample.json: structure, the settings that cause harm when wrong, and that no script action single-quotes a placeholder |
tests/test_readme.py |
6 | That the counts in this section, and the heading above, still match the suite |
python3 -m unittest discover -s tests -t tests -v # 535 tests, 11 skipped
MACROVOICE_INTEGRATION=1 python3 -m unittest discover -s tests -t teststest_harness.py is a branch-for-branch port of macrowhisper's isValidRecordingMetaJson
(Utils/RecordingReferenceResolver.swift:34-53). Every generated document is asserted against
it, which is what lets the adapter be verified without macrowhisper running. If macrowhisper
changes that gate, update the port; tests/test_harness_port.py will show what drifted.
The escaping matrix covers transcripts that break naive JSON emitters: quotes, backslashes,
CRLF, NUL, control characters, astral-plane codepoints, combining accents, RTL, 100k characters,
JSON lookalikes and shell metacharacters. Other tests assert name monotonicity across 10,000
sequential calls, that a watcher thread never observes a directory without its meta.json, and
zero transcript loss under 12-thread concurrency.
Several regression tests exist because the failure they guard is a hang, so they carry hard timeouts. A future-dated folder, or one whose name starts with a letter, used to make the publisher spin forever waiting for the clock to overtake it. Separately, the CLI used to read stdin before checking whether the environment already held the transcript, so a caller that opened a pipe and never closed it (launchd, cron, CI, a backgrounded shell) blocked forever before the transcript reached the spool, which is the one place it cannot be lost from.
The integration tests are opt-in because they launch a real daemon. They confine themselves to a
temporary watch directory and a temporary config, so ~/macrovoice and ~/.config/macrowhisper/
are never touched, and they use a shell action rather than a paste, so no Accessibility
permission is needed and nothing is typed into whatever app you have focused. macrowhisper --config persists the path it is given, so the original is captured at import, restored
afterwards, and asserted not to be a temp directory.
Each of these is easy to get wrong in a way that costs you something, and none of them is obvious from either app's own documentation. Provenance is stated per item, because "the source says" and "we measured it" are not the same claim.
It disposes of the synthetic folder macrovoice created in the watch directory. That is all it
does. VoiceInk keeps its own copy of every dictation, and macrowhisper cannot reach it.
Read from VoiceInk's source rather than inferred: when recording stops, VoiceInk builds a
Transcription record and calls modelContext.insert plus save() before the transcription
pipeline runs (VoiceInkEngine.swift:200-206), the pipeline then writes the final text into that
same record (TranscriptionPipeline.swift:146), and the record also stores audioFileURL
(:739), so the audio file is retained too. The delivery routing only chooses
.customCommand afterwards (TranscriptionDelivery.swift:43-46), which means your Mode's output
setting has no bearing on any of it, and no bridge configuration can change that.
If you are setting moveTo: .delete for privacy reasons, you have deleted the copy that was never
the sensitive one. Manage retention in VoiceInk itself: its History, and its audio cleanup
settings.
If a Mode has AI enhancement on, the bridge only ever sees the final, post-enhancement text. Anything the model does to your opening words has already happened.
Measured 2026-08-15 with OpenAI gpt-5.5 and VoiceInk's Default prompt. Dictating
"ask google best pizza in madrid" arrived as:
Ask Google: “Best pizza place in Madrid.”
The trigger still fired, and the detail is the useful part. The sample ships
"triggerVoice": "ask google|google", and each | alternative is anchored independently. Here
ask google matched and the bare google did not, because the text no longer begins with it.
In plain dictations without AI enhancement, which arrive as "Google, what is...", the reverse is
true. Each alternative was the only one that matched in one of the two cases, so list both.
The second effect has no workaround: the model added a colon and curly quotes (U+201C and
U+201D, not ASCII), macrowhisper strips the matched trigger but not the punctuation around it, and
the residue lands in your action's payload. The search above ran for Best pizza place in Madrid."
including the stray quote. Harmless for a web search; it matters for any action that parses,
compares or greps the text, and it compounds the NFD note below, since these quotes are non-ASCII
too.
Treat this as one measurement, not a law: one model, one prompt, two dictations. A different model or a prompt that rewrites more aggressively could still drop the trigger word entirely.
Use {{xml:note}} in an action and the tag's content is extracted correctly. But {{swResult}} in
that same action still carries the raw <note>...</note> markup.
This is specific to the bridge and cannot happen upstream. processAllPlaceholders
(Placeholders.swift:1759-1780) branches on which field holds the text: when llmResult is
present it writes the cleaned text back (updatedMetaJson["llmResult"] = cleaned), but when only
result is present it discards the cleaned copy (let (_, tags) = ...) and never updates
result. The bridge deliberately never sets llmResult, because doing so flips macrowhisper's
validation gate to require a non-empty llmResult that VoiceInk cannot supply, so the second
branch is the only one we ever take. Verified in the source and confirmed against a live daemon in
tests/test_integration_macrowhisper.py.
Use double quotes. "{{swResult}}", never '{{swResult}}'. Measured 2026-08-17 against a
real daemon, after this repo shipped the wrong one until that date.
macrowhisper escapes a placeholder for the action type it lands in. For shell that is
escapeShellCharacters (ShellUtils.swift:4-11), which backslashes exactly four characters:
backslash, double quote, backtick and dollar. The single quote is not among them, because the
escaping is written for a double-quoted context, which is what upstream's own examples use.
Put the placeholder in single quotes and an apostrophe in your dictation ends the string early. What follows is then read as shell syntax. Three outcomes, none of which announces itself:
| You dictate | With '{{swResult}}' |
With "{{swResult}}" |
|---|---|---|
don't forget the milk |
the action aborts, and that dictation is gone | logged verbatim |
don't stop, it's fine |
logged as three lines with the apostrophes stripped | logged verbatim |
anything with an apostrophe and a ; |
the words after the ; run as a command |
logged verbatim |
she said "hi", `date`, $HOME, a\b |
arrives with stray backslashes | arrives byte-faithful |
The last row is why quotes used to look backslashed in fired.log. That was never inherent; it
was the wrong quote.
Unquoted is not a third option: the shell would split your dictation on spaces and expand any *
in it. The same rule applies to AppleScript actions, whose escaping (escapeAppleScriptString)
covers only backslash and double quote.
tests/test_sample_config.py enforces this on the shipped sample, and
ApostropheInShellActionTest proves it end to end against a real daemon.
defaults.autoUpdateConfig defaults to true, and on every startup macrowhisper reloads the
config, normalises it, and writes it back (main.swift:1871-1876 calling updateConfiguration(),
which ends in saveConfig()). So the file will stop matching whatever you copied in: expect added
keys, a $schema and configVersion entry, and different formatting.
Measured on a real install: a config copied from macrowhisper.sample.json with 8 keys under
defaults had 28 a few days later, plus $schema, configVersion, scriptsAS and shortcuts,
and a macrowhisper.json.backup.pre-v2 file sitting beside it from the version migration.
Nothing is lost, and a pre-migration backup is written, but do not be surprised when the file no
longer matches the sample, and do not keep your config under version control expecting it to stay
put. Set defaults.autoUpdateConfig: false to opt out, at the cost of an "Outdated Config"
notification if your file is ever behind.
Clipboard context is degraded, and the amount is calculable. macrowhisper captures
pre-recording clipboard when the folder appears, looking back clipboardBuffer seconds. Under
Superwhisper the folder appears when recording starts; here it appears when dictation ends.
With the 5-second default, any dictation longer than 5 seconds loses its pre-recording clipboard
entirely. The sample config sets 60.
{{selectedText}} reflects post-dictation state, not what you had selected when you began
speaking.
Only the final text exists. VoiceInk exposes no raw-versus-enhanced split, so result
carries the final text and languageModelName/llmResult are left absent. {{llmResult}} will
be empty; use {{swResult}}.
No segments, so {{segments}} is empty. No duration: VoiceInk does not expose one,
and macrowhisper reads that field as milliseconds, so a placeholder 0.0 would render as "0ms"
and look measured. The key is omitted, and {{duration}} renders empty instead.
Accented text is NFD. VoiceInk emits combining marks, so café arrives as e + U+0301, not
U+00E9. macrovoice is byte-faithful and never normalises, and .autoPaste passes it through
unchanged. This only matters if you write actions that grep or compare dictated text: a café
typed into a config is NFC and will never match. Normalise both sides.
Per-Mode, not global. Only Modes set to Custom Command feed the bridge.
Front-app placeholders and voice, app, URL and mode triggers are unaffected, since those resolve at action time.
| Path | Purpose |
|---|---|
macrovoice/transcript.py |
Resolve the transcript from env, with stdin fallback |
macrovoice/meta.py |
Build and serialize the meta.json document (pure) |
macrovoice/publisher.py |
Staging, spool, drain lock, atomic renames |
macrovoice/cli.py |
Wiring, logging, exit-code policy |
macrovoice/listener.py |
Asks macrowhisper whether anything is listening before publishing |
macrovoice/watch.py |
Which watch root a bare invocation uses, and the ~/mw-bridge fallback |
macrovoice/doctor/ |
Read-only inspection of the whole setup, see Diagnosing a broken setup |
test_harness.py |
Port of macrowhisper's own validation gate, used as the test oracle |
macrovoice.sh |
The one-liner you paste into VoiceInk |
probe.sh |
Captures what VoiceInk actually sends |
macrowhisper.sample.json |
Ready-to-use macrowhisper config |
Start with ./macrovoice.sh doctor --check. Nine of the thirteen setup traps this project
has hit in practice are detected there, including the three that silently look like the bridge
being broken.
| Symptom | Cause |
|---|---|
| Nothing happens at all | Is your Mode default or shortcut-bound? See Picking the Mode |
| Text pastes normally, your command never runs | The same thing. The Mode is inert |
| The bridge publishes but nothing fires | Does macrowhisper's saved config match? macrowhisper --get-config, then check its watch |
doctor warns about the watch directory name |
You are on the old ~/mw-bridge. Harmless. See Upgrading |
Both ~/mw-bridge and ~/macrovoice exist |
A half-finished migration. macrovoice uses ~/macrovoice; make sure macrowhisper does too |
| Some dictations do nothing | Check macrowhisper's log for burst protection or older than existing, then raise --gap |
| Text appears but no paste | macrowhisper needs Accessibility permission, and the daemon must be restarted after you grant it |
| A dictation closed my draft | simEsc. See the section above |
| Config edits appear to do nothing | Use macrowhisper --action, not a file edit while the daemon runs |
Quotes look backslashed in fired.log, or an apostrophe loses the dictation |
Your shell action wraps {{swResult}} in single quotes. Use double quotes. See Quoting a placeholder in a shell action |
| Transcript missing entirely | Check ~/macrovoice/macrovoice.log and ~/macrovoice/.spool/, or force it with --drain-only |
| A voice trigger fired but nothing pasted | Expected. A matched trigger runs instead of your default action, not alongside it |
| My config file changed on its own | autoUpdateConfig, which defaults to true. See Five things that will surprise you |
| Stray quotes or punctuation in a triggered search | AI enhancement reshaped the dictation before the bridge saw it. Same section |
macrowhisper --status's exit code is useless as a liveness check: it exits 0 either way.
Its output is not: it prints the literal line macrowhisper is not running. when nothing is
listening, which is exactly what doctor's own liveness check reads. To lower --gap, bisect
downward and use macrowhisper's own log as the oracle:
grep -iE "burst protection|older than existing" ~/Library/Logs/Macrowhisper/macrowhisper.logProven end to end against live VoiceInk 2.1 and macrowhisper 2.1.1. A dictation goes from
VoiceInk through macrovoice to a synthetic meta.json and out through stock macrowhisper,
which pastes it with smart casing and spacing; --mode reaches triggerModes, and voice
triggers fire. Verified at real human speaking cadence: five dictations in ten seconds all
delivered, a 633-character dictation intact, quotes and shell metacharacters clean, and accented
text measured end to end.
Issues and pull requests are welcome. Please open a discussion first if you plan a larger change, so we can align on the approach.
Released under the MIT License.
Independent personal project, not affiliated with VoiceInk, macrowhisper, or Superwhisper. Line references describe third-party source as audited at macrowhisper 2.1.1 and VoiceInk 2.1.