Eduardo Silveira
All projects

Gaithano Speak

System-wide dictation for macOS where the audio never leaves the machine and the state machine performs no I/O.

Status
In daily use. macOS 26+, Apple Silicon only.
Stack
Swift 6 · SwiftUI · FluidAudio / Parakeet TDT v3 · Apple Foundation Models · GRDB · MLX
Size
~15k lines of Swift across two targets
Links
GitHub

Hold Option anywhere on the system, speak, release. Polished text is inserted into whatever app had focus. Transcription runs on-device through Parakeet TDT v3 on the Neural Engine, and the default formatter is Apple's on-device Foundation Model, so a normal dictation makes no network request at all.

That is the pitch. Most of the work is somewhere else.

The reducer is the product

The app is two targets, split by testability rather than by layer. Packages/SpeakCore/ is pure and Sendable — no AppKit, no CoreML — and holds the state machine, the prompt builder, the deterministic cleanup pass, the personal dictionary and the GRDB layer. GaithanoSpeak/ is the app: audio capture, a CGEventTap for the hotkey, the engines, insertion, and the SwiftUI overlay.

DictationStateMachine is a pure reducer — handle(DictationEvent) -> [Effect]. It does no I/O and owns no clock. Timing-derived events (minHoldElapsed, lockWindowElapsed, maxDurationReached) come back in from timers the coordinator schedules in response to effects. RecordingCoordinator owns the machine and executes its effects against the real subsystems.

The reason for the split is that the hard parts of this app are all state, not signal processing: double-tap lock, Option-as-modifier passthrough, secure-input rerouting, the cancel paths. Putting them in a reducer means each one is a test rather than something you verify by holding a key and hoping.

Option is a terrible hotkey, which is why it is interesting

People press Option all the time — ⌥←, ⌥e, ⌥Tab, Raycast. So optionDown starts capture provisionally and silently, and dictation only commits after a minimum hold: sound, overlay, media pause. Any other keypress inside that window silently abandons everything. The monitor never consumes the modifier, and consumes Esc only while a dictation is actually running.

When a password field has focus the machine moves to .blockedSecureInput and the hotkey disarms. If secure input turns on mid-dictation, insertion reroutes to the clipboard instead of synthesized keystrokes, because synthesized keystrokes into a secure field go nowhere.

The dictionary learns from the formatter

DictionaryLearner aligns the text handed to the model against the text it returned, and proposes personal-dictionary entries from the differences. The point is that the model fixes proper nouns with sentence context the dictionary pass can never see; recording those fixes makes them permanent, and makes them work on the short-utterance and offline paths where no model runs at all.

The gates are the feature, not the learning. Almost everything a formatter changes is grammar, and grammar is not vocabulary. A candidate has to clear two independent tests: it has to look like a term (internal capital, a digit, internal punctuation — which keeps GRDB, CoreML, .NET and rejects Their), and it has to be phonetically close to what was heard. More than a handful of candidates in one utterance means the model paraphrased rather than corrected, and the whole batch is discarded.

Loosening either gate is how this feature would start quietly corrupting dictations, because a wrong entry reads as correct to the person who meant to say it. Casing fixes promote themselves after enough sightings; spelling fixes only ever suggest.

What I measured

Latency is the product here, so most of the design came out of a stopwatch.

  • The on-device formatter is ~5–9s cold and ~0.5–3s warm, so prewarm() fires when recording starts rather than when a transcript exists.
  • One environment variable, MAX_THINKING_TOKENS=0, is the difference between 1.5s and 16s on the optional CLI formatter: left on, the model spends ~530 output tokens reasoning about where a comma goes before emitting a 17-token sentence. Setting a small budget instead does not work — the API clamps it up to its own minimum. It is binary.
  • Turning thinking off made every subtle line of the prompt load-bearing. The model started producing a refusal with an explanation for a dictated sentence about deleting files — worse than either failure alone, since the apology gets typed into the user's document. Both regressions are prompt fixes now, both are pinned by tests, and a runaway check backstops them: output more than 3× the input means the model broke character, so it throws and lands on deterministic cleanup.
  • Speech synthesis needs a resident process. Loading the TTS model costs ~13s and generation runs at roughly 1× realtime, so spawning per utterance would put a 13-second floor under every spoken answer — longer than the model call that produced it.

Honesty about the network

Two things can send words off the machine and both are choices made in Settings, with blunt non-collapsible warnings. Audio never leaves, ever. The default dictation formatter never leaves. Picking the CLI formatter sends every utterance in every app to a provider, and asking a question with the Talk gesture sends what you asked.

I wrote those warnings deliberately plainly. Someone picking an option out of a dropdown has no other cue that local-first just stopped applying.