Dictate using Wispr Flow running on a Mac, from your Linux machine. Hold F9, speak into the machine's own microphone, and the transcript is typed into whatever window has focus. Flow has no Linux client; this package does not need one.
The microphone audio travels over the tailnet to a Mac, where a virtual audio device presents it to Wispr Flow. The transcript comes back through Flow's own local database. Nothing but audio ever leaves the Linux machine - no files, no transcripts, no telemetry - and the microphone it captures is muted whenever the key is not held. (The sender stays attached between presses: that is what makes a warm press ~260 ms instead of a full rebuild, and what the "roc-send killed while idle" row in Failure modes costs you.)
Linux machine Mac (tailnet)
------ -------------
Hyprland F9 bind roc-vad virtual input "Remote Mic"
| ^ |
v | v
Dictation Mic (echo-cancel/WebRTC) | Wispr Flow (mic pinned)
| | |
roc-send -i pulse://dictation_mic ----------+ v
RTP + rs8m FEC flow.sqlite History row
^ |
| ssh poll (readonly)
+---- clipboard paste (or wtype) delivers it into the focused window
Two machines on the same tailnet (Tailscale or equivalent), plus:
On the Mac (the brain)
- macOS with Wispr Flow installed and logged in. Flow is macOS-only, and this package does not give you an account for it.
- Remote Login enabled (System Settings > General > Sharing) and your Linux machine's
key in
~/.ssh/authorized_keys, sossh <mac>works with no password. Test that before you start - it is the one thing the installer cannot fix for you. sudoonce, during install: the receiver is a real CoreAudio HAL driver, so it belongs in/Library.sqlite3, which macOS already ships: the Linux machine reads Flow's history over ssh.
On the Linux machine (Omarchy/Hyprland - nothing here needs root)
libpulse(pactl),wireplumber(wpctl),alsa-utils(amixer),wtype- a toolchain and Python, to build roc-toolkit:
base-devel,git,python - optional, for the no-microphone verification recipe:
sox(andffmpegon the Mac)
Arch, in one line:
sudo pacman -S --needed libpulse wireplumber alsa-utils wtype base-devel git python soxThe installer writes to ~/bin, ~/.config, ~/.local and ~/build (the roc
source tree). It never touches system paths, and --uninstall reverses all of it.
Run the Mac side first (it owns the receiving end), then the Linux side.
# 1. On the Mac: clone, then install the receiving end (asks for sudo once)
git clone <repo-url> ~/remote-dictation
bash ~/remote-dictation/install-macos.sh # idempotent; --check, --uninstall
# 2. Flow's microphone is the one step no installer can do for you:
# Flow > Settings > General > Microphone > Change > Remote Mic
# 3. Copy the same directory to the Linux machine and install the sending end
scp -r ~/remote-dictation <linux-host>:~/
ssh <linux-host> 'bash ~/remote-dictation/install-linux.sh --mac <mac>'
# 4. Hold F9 on the Linux machine and speak.Both installers are idempotent and safe to re-run; --check reports state
without touching anything, --uninstall reverses what the installer created.
install-macos.sh --rebind rebuilds the virtual microphone from scratch - that
is the repair path if the receiver ever goes silent (see Failure modes).
- Installs the roc-vad HAL driver (published tarball, sha256-pinned).
- Ensures the audio server has loaded it.
- Creates the receiver device
Remote Micand binds UDP 10001 (source), 10002 (FEC repair), 10003 (control). - Reports Flow's microphone setting.
No background service is involved: the receiver lives inside the audio driver, and the device survives reboots on its own. There is no launchd job to keep alive.
One manual step, once: Flow ships an opaque per-device hash, so the installer cannot select the microphone for you. In Flow: Settings > General > Microphone > Change > Remote Mic. It already persists after that.
- Builds roc-toolkit into
~/.local, statically, with the SoX backend disabled. This retires theLD_LIBRARY_PATHworkaround for roc-toolkit #838 (Arch'ssox_ng 14.8registers more than the backend's 256-driver array can hold, so the stockroc-sendpanics on start-up).--disable-sharedis also load-bearing: with the shared library enabled,scons installfails trying to copy it from a staging directory scons never creates, even though the build succeeded. - Installs the three scripts into
~/bin. - Writes
~/.config/dictation.conf(never overwrites an existing one). - Appends one push-to-talk binding to
~/.config/hypr/bindings.lua, inside a managed block, and reloads Hyprland.
ragel is needed to build openfec (the FEC codec). The installer builds it
unprivileged into ~/.local if it is missing, so no root is required. If your
build fails for a different reason, the log is at ~/build/roc-build.log.
| Action | Result |
|---|---|
Hold F9, speak, release |
transcript is typed into the focused window |
~/bin/dictate.sh start / stop |
same thing from a terminal |
bash install-linux.sh --check |
state of scripts, device, Mac reachability and the Mac's receiver |
While nothing is held, the Linux machine's real microphone is muted, so no other app can listen in. The Dictation Mic node is kept warm between presses to keep bring-up under ~300 ms.
Warm vs cold. Every press after the first is ~0.3 s; the first press after a reboot is ~1 s (device bring-up, streamer start, arming). Nothing needs waiting for: the capture chain hands over its first frame in ~80-110 ms, and the receiver joins the stream the moment a consumer opens the virtual device on the Mac - which is exactly what arming Flow does. Audio therefore appears within ~250 ms of Flow being armed, so the press path arms immediately instead of sleeping first.
~/.config/dictation.conf is sourced on every keypress by both scripts. The config wins over
the environment: it is sourced after the environment is inherited, so DICTATE_MAC=other dictate.sh start has no effect when this file also sets DICTATE_MAC - edit the file, or pass
the value at install time (install-linux.sh --mac HOST, which appends it here). That
precedence silently invalidated a test of the unreachable-Mac path once; check which value
actually applied before drawing conclusions from a failure test.
The keys that matter:
| Key | Default | Meaning |
|---|---|---|
DICTATE_DELIVERY |
auto |
auto pastes via the clipboard when possible (exact bytes: accented characters wtype cannot type, atomic for long text); type forces wtype |
DICTATE_NEWLINE |
space |
enter would submit a chat prompt - keep space unless you want that |
DICTATE_TYPE_DELAY |
6 |
ms between keystrokes; raise to 8 if letters go missing |
DICTATE_TAIL_GRACE |
0.5 |
seconds of extra recording after release |
MIC_SRC_LEVEL |
20% |
the real mic's software fader while dictating; 50%+ clips |
MIC_GAIN |
140 |
the Dictation Mic's own fader |
MIC_MASTER / MIC_CARD |
detected | which source to capture, and its ALSA card |
MIC_MUTE_IDLE |
1 |
mute the real mic whenever not dictating |
MIC_SINK_MASTER |
derived | the echo-canceller's reference sink. Derived from the mic's own card (alsa_input.X -> alsa_output.X); set it only if that card has no matching output. Read the failure-mode table before letting this float |
DICTATE_SENDER_MAX_AGE |
1800 |
seconds; a streamer older than this is rebuilt on the next press |
DICTATE_MAC |
required | ssh host of the brain (must resolve over the tailnet). No default ships with the package: install-linux.sh --mac HOST writes it |
DICTATE_MAC_IP |
unset | pins the Mac's address instead of resolving DICTATE_MAC |
DICTATE_DB |
derived | Flow's database on the Mac. Derived from the remote $HOME on first use and cached; set it only for a non-standard install |
Measured on the reference pair (Omarchy machine, M4 Pro Mac mini, Tailscale):
- F9 press to Flow armed: ~310 ms warm, ~1 s on the first press after a reboot; audio follows ~250 ms after arming, because the receiver joins the stream as soon as Flow opens the virtual device
- capture chain, first frame: ~80-110 ms; first RTP packet at the Mac: 253 ms after the streamer starts (with a consumer holding the device open) and never, if nothing holds it - the receiver only joins while the Mac side is consuming
- measures of "how long the chain takes" must not use
parecdefaults:--latency-msec=5reads 78 ms where the default reads 2401 ms, and that default is buffering, not latency - release to transcript row: ~1.0 s (Flow's own processing)
- row to text typed: ~200 ms for short text, ~200 chars/s for long text
- delivery exactness: 675 characters including 19 capital I's, byte-identical
- wind rejection with the WebRTC high-pass: low-end share 39.7% -> 8.7%
| Failure | Behaviour |
|---|---|
| Mac unreachable / asleep | press fails in ~0.9 s (measured 855 ms), microphone re-muted, no text, toast says so. Cold presses used to report success - that gap was found and fixed on 2026-09-13 |
roc-send killed while idle |
next press rebuilds it (device bring-up + streamer, ~1 s; the first words are not lost because audio starts ~250 ms after arming) |
| capture device removed (on the Linux side) | next press rebuilds device and stream |
| the AEC reference sink follows your default sink | leave sink_master unset and PipeWire attaches the echo-canceller's playback side to the default sink. If that is a removable device (Bluetooth), a BlueZ hiccup stops the filter producing frames entirely: the microphone is fine, both --checks pass, the receiver answers, and dictation delivers silence. The reference is now pinned to the mic's own card. Diagnose with dictation-mic.sh status: aec_link= must name a local sink, not a Bluetooth one. Found 2026-09-13, after two dictations returned nothing |
| a streamer whose capture starved from birth | it stays alive and used to be reused forever, so one bad start poisoned every later press. The press path now checks it - alive, aimed at the right address, not currently starving, not ancient - and rebuilds it when unhealthy |
| receiver device removed (Mac) | a press still arms Flow and delivers nothing. The Mac's --check reports the missing device; the Linux machine's now does too. Flow itself silently falls back to whichever input it can find, so a transcript may arrive from the Mac's room - not a failure mode you can detect from the Linux machine alone |
| Flow not running | the URL scheme relaunches it |
| Flow busy at start-up (marker unreadable) | retried once, then a clean failure - never silently becomes "row 0" |
| Flow logged out | trigger accepted, no transcript, release reports no transcript. Not literally tested - restoring a login needs your credentials, so I did not touch it; it collapses into the verified "no transcript" branch above |
| no transcript at all | release polls for ~7 s (15 ssh round trips, was 40 = 15.3 s), then names the cause when it can: the press watches the capture in the background, so it says "no audio left the microphone" (armed and digitally silent) or "the audio pipeline stalled" (no frames) instead of a bare "No transcript captured" |
a failed device bind (an already-bound slot; also what a device left behind by a rename causes) |
it tears the live receiver down and then fails: the endpoint list still looks right in device show, but nothing is listening - the device is silently dead. Repair with install-macos.sh --rebind, and the installer now recovers on its own, by recreating the device, whenever a bind fails |
The transport can be proved deterministically, with no microphone and no human:
# on the Linux machine: a known tone, matching the device's format
sox -n /tmp/tone.wav synth 5 sine 440 vol 0.6 channels 2 rate 44100
# on the Mac: record the virtual microphone while the tone plays
ffmpeg -y -f avfoundation -i ':1' -t 12 -ar 44100 -ac 2 /tmp/rx.wav &
# on the Linux machine: send that file instead of the live microphone
~/.local/bin/roc-send -i file:///tmp/tone.wav \
-s rtp+rs8m://<mac-tailnet-ip>:10001 -r rs8m://<mac-tailnet-ip>:10002 \
-c rtcp://<mac-tailnet-ip>:10003
# on the Mac: a tone that arrived reads about -11 dB mean; a dead path reads -91 dB
ffmpeg -i /tmp/rx.wav -af volumedetect -f null - 2>&1 | grep mean_volumeThe Mac's driver log confirms it independently: a working stream logs
session router: creating route ... address=<linux-host>:<port> as the sender
starts, and removing session when it ends.
The speech engine is the only part of this package that knows about a specific product.
docs/brain-interface.md describes the small contract - five subcommands, JSON on stdout - that
would let a local Whisper, a cloud STT service or a future Linux client take its place without
touching capture, transport or delivery.
~/bin/type-harness.sh types a test string through the same wtype path dictation uses and
tells you whether it arrived byte-identical. It exists because it caught a delivery regression
in one run that reading the code twice did not.
~/bin/paste-harness.sh does the same for the clipboard-paste path: it puts a test string on
the clipboard, sends Ctrl+V into a real GTK entry (zenity) and compares what arrived. Its
default string is deliberately full of accents - Mañana, ¿qué tal? Ñandú añejo, áéíóú, ü ü -
because accented characters are exactly what wtype cannot deliver and why this path exists.
Both harnesses open one window for about ten seconds and close it again.
It opens a terminal window (one window, about ten seconds, closed again at the end), so run it deliberately rather than in the middle of something:
~/bin/type-harness.sh # default string: 5 capitals, apostrophe, dashes
~/bin/type-harness.sh --delay 12 # after changing DICTATE_TYPE_DELAY
~/bin/type-harness.sh --text "Custom. Text." --keep-openIt refuses to type unless it can verify the scratch window is the focused one, because that is exactly how a test string ends up in someone's editor otherwise.
-
An echo-canceller's reference sink must not be left floating. Unset, it follows the default sink; if that is a Bluetooth headset, the filter's playback side is attached to the headset, and a BlueZ error storm (
suspended -> error,Start error: Input/output error) then stops the filter emitting frames at all - the microphone is healthy and every check on both ends passes while Flow receives silence. Pin it (sink_master=, i.e.MIC_SINK_MASTER), and readaec_link=indictation-mic.sh status. Verified 2026-09-13. -
macOS installers that extract with
tar -xPalways exit non-zero on modern macOS:/usrand/Libraryare firmlinks and cannot be created. Verify the payload, never the exit status. -
Omarchy renders toasts only via
omarchy-notification-send;notify-sendexits 0 and shows nothing. -
Hyprland ignores binds fired by synthesized keys (
wtype -k F9); only a real keypress proves a binding works. -
hyprctlneedsHYPRLAND_INSTANCE_SIGNATURE; without it it prints nothing, which reads exactly like "no binds registered". -
Non-interactive SSH has no
XDG_RUNTIME_DIR, so everypactlcall returns empty - this produced three phantom "device missing" alarms in one session. -
module-echo-cancelinherits its master's volume; muting the mic kills it. -
Three layers re-apply a remembered volume after your write: WirePlumber,
module-stream-restore, and the ALSA mixer state. -
Arch's
roc-toolkitpackage linkslibsox_ng.so.3without declaring it (pacman -S soxfixes it), and the sox backend then panics onsox_ng 14.8(roc #838,sox backend: can't add driver) - hence the private build in this package. The panic comes back if you call a bareroc-sendfrom a non-login shell: PATH resolves to/usr/bin/roc-send, which has the sox backend. Use~/.local/bin/roc-send(or the scripts) in anything scripted - verified 2026-09-13, where a bare call core-dumped and the same command with the absolute path sent the tone cleanly. -
roc-send -Lprints the scheme list on stderr, not stdout, so a2>/dev/nullmakes a perfectly good binary look broken. -
... | grep -qunderset -o pipefailfails spuriously: grep closes the pipe early, the producer dies of SIGPIPE, and the pipeline reports failure even when the pattern matched. Capture the output, then match it. -
roc-vad device bindon an already-bound slot tears the live receiver down and then fails - "looks configured" is not "is listening". The Mac installer now recovers by itself when a bind fails: it deletes the device (which releases the stale slot and its UDP ports), recreates it and binds again - the state a rename leaves behind, where the old device still owns :10001. -
roc-vad device showtakes an index, not a uid (device show remote-mic→invalid index "remote-mic", not a number, and it still exits 0). Get the index fromroc-vad device list, where the uid is column 4 - the Mac installer's awk one-liner does exactly that. A probe that trusts the exit status here always "passes". -
dictation.confoverrides the environment (see Configuration): a failure test that setsDICTATE_MAC=...on the command line silently tests the configured host instead. -
wtype's modifier options do not work on this stack. Measured 2026-09-13 over four variants --M shift -k l -m shiftwith-s 6/-s 20/-s 30spacing, plus-k Land-M shift -k L- every one produced a lowercasel, while plain text typing producedLcorrectly (36/36 byte-identical runs of a 5-capital string at-d 0,6and12). Consequences: capitals must be left to wtype's text inference, and theshift-enternewline mode was removed on 2026-09-13 rather than shipped broken - it could only emit a plain Return, which submits a prompt. An old config value now degrades tospacewith one notice. The original note, for the record: theshift-enternewline mode (which sends-M shift -k Return) behaves like a plain Return, i.e. it SUBMITS - keepDICTATE_NEWLINE=space; and clipboard paste cannot be synthesised either, since the paste key is a modifier combo. -
Non-ASCII characters are silently dropped by text typing.
cafe Léo accionarrived wherecafé Léo acciónwas sent, on a Spanish layout: wtype maps a codepoint to a keysym and cannot type one the layout has no single key for (é needs a dead-key sequence). This is a real limitation for accented dictation and has no clean fix while modifiers are unavailable - worth knowing before promising Spanish support. -
roc-vad only joins a session once a consumer opens the device on the Mac. Nothing listens on UDP 10001 until Flow (or any recorder) captures "Remote Mic", so from the sending side there is no readiness signal to wait for - the packets simply do not arrive until the Mac side is consuming (verified: none in 12 s, then 253 ms after a recorder opened it). Do not gate the press on that: arm Flow, and audio follows.
-
Measure the capture chain with an explicit latency, never with
parecdefaults.parec --latency-msec=5reads 78 ms for the first frame where the default reads 2401 ms; the default is buffering, and reading it as "the chain takes 2.4 s to wake" cost a bogus fix attempt and a slower cold path until it was caught.
ssh <linux-host> 'bash ~/remote-dictation/install-linux.sh --uninstall'
bash remote-dictation/install-macos.sh --uninstall # --purge removes the driver too