Voice dictation · by Wispr AI, Inc.
Build your own Wispr Flow.
Hold a key, talk, and clean text shows up in any app. Wispr Flow sends your voice to its servers for $15 a month. You can keep it on your laptop for free.
What you're paying now
$720 per year
Flow Pro for a 5-person team: $12 × 5 users × 12 months on annual billing. $900 if billed monthly.
People who built it
–
Counting…
Build it in any tool
Pick where you work. The agent fetches everything it needs.
Claude Code. Run this in a terminal from an empty folder. Claude Code fetches the spec and starts building.
claude "Build my own Wispr Flow. Fetch https://buildyourown.software/like/wisprflow/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
01
What it does
Wispr Flow is a dictation app. You hold a hotkey, say what you want to write, let go, and the text is typed into whatever app has focus: Slack, Gmail, Notion, your terminal, anything with a text field. It removes the ums, adds punctuation, catches mid-sentence corrections, and formats lists when you rattle one off.
Around that it sells a personal dictionary for names and jargon, a tone setting per app (formal in Mail, casual in Slack), snippets that expand from a spoken phrase, a command mode that edits selected text by voice, a meeting notetaker, and phone keyboards for iOS and Android. Teams get a shared dictionary, SSO, HIPAA, and admin controls on the higher tiers.
What you actually use every day is the loop: record, transcribe, clean, paste. All four steps can run on your own Mac. NVIDIA's Parakeet model does the transcribing on the Apple Silicon Neural Engine in under a second. Apple's on-device model or a small Ollama model does the cleaning. The Accessibility API does the pasting. No API key, no account, and nothing leaves the laptop after a one-time model download. That's a menu bar app you can build in a few days.
02
What it costs
List prices from the vendor's own pricing page. The fine print is where the money goes.
Free
$0
per month
- –2,000 words per week on desktop, 1,000 per week on iPhone
- –Dictation on Mac, Windows, iOS, Android
- –Dictionary and snippets
- –Limited Notetaker access (Mac only)
Pro
$15
per user / month billed monthly ($12 on annual billing)
- –Unlimited dictation
- –Extended Notetaker history
- –Shared dictionary and snippets for teams
- –Team workspace, central billing, usage analytics
Students and educators get 50% off with an education email.
Growth
from $23
per user / month billed monthly ($18 on annual billing)
- –SAML single sign-on
- –Org-wide HIPAA enforcement with a BAA
- –Admin control over model training
- –Unlimited Notetaker access
Enterprise
Custom
per year, annual only
- –SCIM provisioning
- –Audit logs and MDM deployment
- –Domain capture
- –Dedicated support with SLAs
Contact sales.
The fine print
- The Free plan caps you at 2,000 words a week on desktop. If you dictate emails all morning, you hit it by Tuesday.
- The $12 price needs annual billing paid up front. Month to month is $15, 25% more.
- Every seat pays the same rate. A teammate who dictates twice a week costs as much as the person who lives in it.
- Your audio and text go to Wispr's servers to be transcribed and cleaned. You need the Growth plan before an admin can turn off model training.
- SSO and HIPAA start at 'from $23' per user, and that word 'from' means the price can go up from there.
- The Notetaker is free 'through October 31'. Promo pricing usually means it costs something after that.
Prices checked September 10, 2026 at wisprflow.ai. They change. Check before you quote them.
03
What you actually use
The headline features, and an honest call on whether a typical team needs each one.
7 of 13 headline features are worth building for a typical user. The rest is what you're paying for.
- Build it
Push-to-talk hotkey
Hold a key to record, release to insert. Works from any app.
Why: This is the whole interaction. One global event tap and a state machine.
- Build it
Local transcription
Speech to text runs on the Mac. Interim words show while you talk, the final pass runs when you let go.
Why: Parakeet TDT 0.6B on CoreML transcribes a 10 second clip in well under a second on an M-series chip. No key, no bill, no upload.
- Build it
AI cleanup
Drops fillers, adds punctuation, applies self-corrections, formats lists.
Why: This is the reason people pay. It's one well-written prompt and a small local model.
- Build it
Tone per app
Casual in Slack, formal in Mail, plain text in the terminal.
Why: Read the frontmost app's bundle id and add a line to the prompt. Ten lines of code.
- Build it
Personal dictionary
Names, product terms, and jargon the model should get right.
Why: You'll add your own last name on day one. Feed it to the cleanup prompt so it fixes what the speech model misheard.
- Build it
Types into any app
Inserts at the caret in native apps, browsers, Electron apps, and terminals.
Why: Accessibility API first, clipboard paste as the fallback. Handle the fallback well and it works everywhere.
- Build it
History
Every dictation with the raw transcript and the cleaned version.
Why: You'll want to recover something the paste ate, and to see what the cleanup changed.
- Maybe
Snippets
Say 'my calendar link' and get the URL.
Why: A table of trigger phrase to expansion, applied before cleanup. Add it when you want it.
- Maybe
Command mode
Select text, hold a second hotkey, say 'make this shorter'.
Why: Useful, and mostly the same pipeline with the selection passed to the model. Do it in the second week.
- Maybe
25 languages
Dictate in English, Spanish, German, French, and 21 other European languages with auto-detection.
Why: Parakeet v3 is multilingual out of the box. Wispr claims 100+, but if yours is on the list of 25 you're covered.
- Skip
Meeting notetaker
Records meetings, identifies speakers, writes notes.
Why: Different product with a different pipeline. Build it separately if you want it.
- Skip
Mobile keyboards
iOS and Android keyboards with the same dictation.
Why: Custom keyboard extensions are a real project. Your phone's built-in dictation is fine.
- Skip
Team workspace, SSO, HIPAA
Shared dictionary, central billing, SAML, BAA, admin controls.
Why: Nothing leaves the laptop, period. There's no server to protect, so there's nothing for a BAA or an admin panel to govern.
04
How it works under the hood
The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.
Data model
Dictation
id · started_at (UTC) · duration_ms · app_bundle_id · app_name · raw_text · cleaned_text · inserted_text · insert_method (ax | paste | clipboard_only | failed) · cleanup_backend · cleanup_model · stt_ms · cleanup_ms · error
Store timestamps in UTC and group by local day in the history window. Text columns are nullable so a 'don't store transcripts' setting can leave them empty.
DictionaryEntry
id · term · sounds_like (nullable) · use_count · created_at
sounds_like is how the speech model usually mishears it, e.g. 'wall chunk' for 'Wolchonok'.
AppStyle
bundle_id (PK) · app_name · tone (formal | neutral | casual) · hint · format (plain | markdown)
Seed Slack, Messages, Mail, Terminal, and iTerm. A row for '*' is the default.
Snippet
id · trigger_phrase · expansion · created_at
ProviderConfig
model_path · model_checksum · cleanup_backend (apple | ollama | rules | off) · ollama_model · hotkey · hotkey_mode (hold | toggle)
All of it in UserDefaults. There are no API keys, so there's no Keychain and no secret to leak.
Key flows
First run model download
- 1.Onboarding shows the model name, the size (about 600 MB), and where it will live: ~/Library/Application Support/Flow/models.
- 2.The app downloads the Parakeet TDT 0.6B v3 CoreML files from Hugging Face with a progress bar, then verifies each file's SHA-256 and shows the checksum in Settings.
- 3.A 'Load from folder' button accepts a folder someone copied over on a USB stick, for Macs that can't reach the internet.
- 4.The model compiles on first load (10 to 30 seconds), then loads from cache in about a second on every launch after that. This download is the only network request the app ever makes.
Dictate
- 1.User presses and holds the hotkey. The app records the frontmost app's bundle id and starts the mic.
- 2.Audio is resampled to 16 kHz mono and buffered. About once a second the app runs Parakeet over the buffer so far and shows the words in a small floating overlay that never takes focus.
- 3.User releases the key. VAD trims leading and trailing silence, gain is normalized, and Parakeet runs once over the whole buffer for the final transcript.
- 4.Snippets expand. The raw text goes to the local cleanup model with the dictionary and the app's style hint.
- 5.The cleaned text is inserted at the caret. A history row is saved with raw, cleaned, timings, and how the insert happened.
Cleanup pass
- 1.Build a system prompt: remove fillers, fix punctuation, apply self-corrections, format lists, keep the speaker's words otherwise.
- 2.Append the dictionary terms and the app's tone and hint.
- 3.Send it to Apple's on-device model through the Foundation Models framework if Apple Intelligence is on, otherwise to Ollama on localhost, otherwise to the built-in rule-based cleaner. 4 second timeout.
- 4.If the call fails, times out, or returns something wildly longer or shorter than the input, insert the raw transcript instead and record the error.
Insert text
- 1.Ask the Accessibility API for the focused element. If it's a text field and accepts a selected-text write, set it and read back to confirm.
- 2.Otherwise snapshot the clipboard, write the text, post Cmd+V to the frontmost app, wait 150 ms, restore the clipboard.
- 3.If no text element is focused, leave the text on the clipboard and show a notification that says so.
Add a dictionary term
- 1.Right-click a word in the history window (or add one in the Dictionary window).
- 2.Optionally type what the speech model heard instead.
- 3.From then on the term and its sounds-like spelling are listed in the cleanup prompt, and the rule-based cleaner does a direct sounds-like replacement.
Integrations
- FluidAudio (Parakeet TDT 0.6B v3 on CoreML)requiredOn-device speech to text, VAD, and chunked inference for interim words. Swift package.
- macOS Accessibility APIrequiredFind the focused text field and insert text, plus the global hotkey event tap.
- AVFoundation and AcceleraterequiredMicrophone capture, resampling to 16 kHz, and gain normalization.
- SQLite via GRDBrequiredHistory, dictionary, app styles, snippets.
- Hugging Face (one-time download)requiredWhere the model files come from on first run. Never contacted again.
- Apple Foundation ModelsoptionalThe cleanup model on macOS 26 with Apple Intelligence on. No download, no setup.
- OllamaoptionalThe cleanup model on macOS 15, or when Apple Intelligence is off. Runs on localhost:11434 with qwen3:4b or gemma3:4b.
- sherpa-onnxoptionalFallback speech engine with its own Parakeet ONNX export and Swift bindings, if FluidAudio doesn't fit.
- SparkleoptionalAuto-updates if you share the app with your team.
05
The build prompt
The full spec the one-line command points at. Web apps here all follow the same one-box setup: one process, SQLite, local disk, no hosted services. Paste it directly if your tool can't fetch URLs.
# Build a fully local voice dictation menu bar app for macOS (replacing Wispr Flow)
You are building a macOS menu bar app for one person. It replaces Wispr Flow: hold a hotkey, talk, release, and cleaned-up text is inserted into whatever app has focus. Everything runs on the Mac. Speech to text is NVIDIA's Parakeet TDT 0.6B v3 running on CoreML through the FluidAudio Swift package. The cleanup pass uses Apple's on-device Foundation Model when it's available, Ollama on localhost when it isn't, and a rule-based cleaner when neither is. There are no API keys, no accounts, no Keychain, and no cloud calls. The only network request the app ever makes is a one-time model download on first run. It keeps a history with a raw vs cleaned toggle. Build it end to end. Latency, reliable insertion, and the privacy guarantee matter more than features.
## Stack
### Requirements
- An Apple Silicon Mac. Parakeet on CoreML uses the Neural Engine, and Intel Macs are too slow for the once-a-second interim pass.
- macOS 15 or newer. macOS 26 if you want the Apple Foundation Model as the cleaner; on macOS 15 the Ollama path is the default.
- Xcode from the App Store (free). CoreML, the CoreML compiler, and the macOS 26 SDK that has the Foundation Models framework all ship with it, and the Command Line Tools alone don't include them. You never have to open the Xcode GUI. Run `sudo xcode-select -s /Applications/Xcode.app` once, and everything after that is `swift build` from the terminal.
- Ollama (`brew install ollama`) is optional. It's only needed on macOS 15, or on macOS 26 with Apple Intelligence off.
A Python route exists for people who refuse to install Xcode (`parakeet-mlx` for speech, `rumps` for the menu bar, `pynput` for the hotkey), but inserting text into other apps is much less reliable from Python, and this prompt does not cover it.
### Why Swift instead of Electron
- Every hard part of this app is a native macOS API: the global hotkey event tap, the Accessibility API, microphone capture, the menu bar item, CoreML, and the Foundation Models framework.
- In Electron each of those needs a third-party native module (uiohook, robotjs or nut.js, a Node CoreML binding that doesn't really exist). Those modules are where builds break and where an agent gets stuck for hours.
- Swift Package Manager compiles from the terminal with `swift build` and runs tests with `swift test`.
Concretely:
- Swift 6.0 or newer, macOS 15 deployment target. Guard the Foundation Models code with `#available(macOS 26, *)` so one binary runs on both.
- SwiftUI for windows (Onboarding, Settings, History, Dictionary). AppKit for the status item (`NSStatusItem`), the overlay (`NSPanel`), and everything that touches the Accessibility API.
- One Swift package with three targets: `Flow` (executable, AppKit allowed), `FlowCore` (library, all logic, no AppKit imports), `FlowCoreTests`.
- Dependencies: `GRDB.swift` for SQLite and `FluidAudio` (`https://github.com/FluidInference/FluidAudio`) for Parakeet, VAD, and audio conversion. Nothing else unless you must.
- Ollama is called with plain `URLSession` against `http://127.0.0.1:11434`. No SDK. The Foundation Models framework is imported directly.
- `URLSession` is allowed in exactly two places: `ModelDownloader` and `OllamaCleaner`. Put a comment at the top of each saying so. Nothing else in the package may import a networking API. Tests enforce this.
- `scripts/bundle.sh` assembles `build/Flow.app` from the SwiftPM binary: copy the executable into `Contents/MacOS`, write `Contents/Info.plist`, copy `Resources/AppIcon.icns`, then `codesign --force --deep --sign - build/Flow.app` (ad hoc).
- `Makefile` with `make build`, `make bundle`, `make run` (bundle then `open build/Flow.app`), and `make test`.
- `Info.plist` must set `LSUIElement = true` (no Dock icon), `NSMicrophoneUsageDescription`, and `CFBundleIdentifier = com.yourname.flow`. The bundle id matters: macOS ties the Accessibility and Microphone grants to it.
- The ad hoc signing trap, which will bite on the second build: an ad hoc signature's designated requirement is the code hash, so every rebuild is a different app to TCC. macOS silently drops the Accessibility grant, the toggle in System Settings still shows Flow as on, `AXIsProcessTrusted()` returns false, and the event tap fails. Neither relaunching nor clicking the toggle again fixes it; only turning it off and on, or `tccutil reset Accessibility com.yourname.flow`. Handle it three ways: (1) ship `scripts/make_signing_cert.sh` that creates a self-signed "Flow Dev" code signing identity in the login keychain (openssl cert with the codeSigning EKU, `security import`, `security add-trusted-cert -p codeSign`), and have `bundle.sh` sign with that identity when `security find-identity -v -p codesigning` lists it, falling back to `-` otherwise and printing a warning; (2) poll `AXIsProcessTrusted()` once a second for the life of the process and install the tap the moment it flips to true, no relaunch; (3) when the tap can't be created and the app was previously trusted, say so in onboarding in plain words: "Already switched on for Flow? Turn it off and on again. A rebuilt app loses the grant." Test this by rebuilding twice and dictating after each.
Repo layout:
```
Package.swift
Sources/Flow/ App.swift, StatusItem.swift, Overlay.swift, Hotkey.swift, AXInserter.swift, AudioEngineSource.swift, AppleCleaner.swift, Windows/
Sources/FlowCore/ Pipeline.swift, Prompts.swift, Cleaners/, Transcribers/, Audio/, Models/, Store/, Snippets.swift, Clock.swift
Tests/FlowCoreTests/
Tests/FlowTests/
fixtures/
scripts/bundle.sh
Makefile
README.md
```
## Data model
SQLite at `~/Library/Application Support/Flow/flow.sqlite`, managed with GRDB migrations.
- `dictations`: `id` (uuid text PK), `started_at` (text, ISO 8601 UTC with fractional seconds), `duration_ms` (int), `app_bundle_id` (text), `app_name` (text), `raw_text` (text, nullable), `cleaned_text` (text, nullable), `inserted_text` (text, nullable), `insert_method` (text: `ax` | `paste` | `clipboard_only` | `failed` | `cancelled`), `stt_model` (text, e.g. `parakeet-tdt-0.6b-v3`), `cleanup_backend` (text: `apple` | `ollama` | `rules` | `off`), `cleanup_model` (text, nullable), `stt_ms` (int), `cleanup_ms` (int, nullable), `word_count` (int), `error` (text, nullable). Index on `started_at`.
- `dictionary_entries`: `id`, `term` (text, unique, case-sensitive), `sounds_like` (text, nullable), `use_count` (int default 0), `created_at`.
- `app_styles`: `bundle_id` (text PK, `*` for the default), `app_name`, `tone` (`formal` | `neutral` | `casual` | `raw`), `hint` (text), `format` (`plain` | `markdown`), `updated_at`.
- `snippets`: `id`, `trigger_phrase` (text, unique, stored lowercased), `expansion` (text), `created_at`.
Timestamps are stored in UTC and shown in the user's local timezone. The history window groups by local day, so compute "Today" with `Calendar.current` on the converted date instead of matching a string prefix on the UTC value.
All settings live in `UserDefaults`. There are no secrets anywhere in this app:
- `modelPath` (default `~/Library/Application Support/Flow/models/parakeet-tdt-0.6b-v3`), `modelChecksum` (text, the SHA-256 of the manifest, set after download or import)
- `cleanupBackend` (`apple` | `ollama` | `rules` | `off`; default `apple` on macOS 26 with Apple Intelligence on, otherwise `ollama`), `ollamaModel` (default `qwen3:4b`), `ollamaBaseURL` (default `http://127.0.0.1:11434`, must resolve to a loopback address or the app refuses it)
- `hotkey` (key code plus modifier flags, default Right Option), `hotkeyMode` (`hold` | `toggle`, default `hold`)
- `trailingSpace` (default true), `playSounds` (default true), `launchAtLogin` (default false)
- `storeTranscripts` (default true), `historyRetentionDays` (default 90, 0 means forever)
- `historyShowsRaw` (the Raw | Cleaned toggle, default false)
Seed `app_styles` on first launch:
| bundle_id | tone | format | hint |
| --- | --- | --- | --- |
| `*` | neutral | plain | Everyday written English. |
| `com.tinyspeck.slackmacgap` | casual | plain | Slack message. Short. No sign-off. A lowercase first letter is fine for one-liners. |
| `com.apple.MobileSMS` | casual | plain | Text message. Very short. No period on a single short sentence. |
| `com.apple.mail` | formal | plain | Email. Complete sentences. Keep greetings and sign-offs the speaker says. |
| `com.apple.Terminal`, `com.googlecode.iterm2` | neutral | plain | Terminal. Output one line. No trailing punctuation. Never wrap in quotes or code fences. |
| `com.apple.Notes`, `notion.id`, `md.obsidian` | neutral | markdown | Notes. Use markdown lists and headings when the speaker enumerates. |
## Screens
### Window sizes and layout checks
Agents get these wrong more than anything else in this app, so the sizes are fixed here and checked by a script. All numbers are content sizes in points.
| Window | Initial size | Minimum | Resizable | Notes |
|---|---|---|---|---|
| Onboarding | 560 × 480 | fixed | no | Three steps fit without scrolling. Centered on the screen that has the mouse. |
| Overlay pill | 420 × 44, grows to 420 × 100 | 200 × 44 | grows with text only | Bottom center of the screen that has the frontmost app's window, 80 pt above the bottom edge. Never wider than the screen minus 40 pt. |
| History | 820 × 560 | 640 × 400 | yes | Frame autosaved under `HistoryWindow`. The list scrolls; the toolbar and footer don't. |
| Dictionary | 520 × 440 | 420 × 320 | yes | Frame autosaved under `DictionaryWindow`. |
| Settings | 560 × 520 | fixed width, height per tab | no | Standard macOS preferences layout: each tab sets its own height and the window animates between them. |
Rules that apply to every window:
- Set the size explicitly. For SwiftUI windows use `.frame(minWidth:idealWidth:minHeight:idealHeight:)` on the root view and `.windowResizability(.contentSize)` on the `Window` or `Settings` scene. For AppKit windows call `setContentSize(_:)` once before the first `makeKeyAndOrderFront`. Never leave a window at SwiftUI's default size.
- If a SwiftUI view is hosted in an `NSWindow` through `NSHostingController`, set `hostingController.sizingOptions = []` before assigning it as `contentViewController`. The default lets the view's ideal size drive the window, so a stepper like onboarding opens tiny on the first step and balloons on the download step. Give long paragraphs `.fixedSize(horizontal: false, vertical: true)` so they wrap instead of widening the window, and truncate paths and checksums with `.lineLimit(1).truncationMode(.middle)`.
- Nothing is clipped. Every label, button, and table fits at the initial size with the system font at the default text size, and again with "Larger Text" in System Settings set one step up.
- Windows open centered on the screen that contains the mouse (`NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) }`), never partially off-screen, and never taller than 90% of that screen's `visibleFrame`.
- Tables and lists get the remaining vertical space; fixed-height chrome (toolbars, segmented controls, footers) never grows with the window.
- The overlay has no title bar, no shadow gap, 12 pt corner radius, and 12 pt inner padding. Measure the pill's width from its text, not from the panel's default frame.
The check: add a `--check-windows` launch flag (also `make check-windows`). In that mode the app opens every window in the table, waits one second for layout, and for each one prints a line `WINDOW <name> <width>x<height> min=<w>x<h> screen=<w>x<h> clipped=<count>` where `clipped` is the number of views whose `intrinsicContentSize` is wider or taller than the frame they got (walk the view tree, or in SwiftUI use `GeometryReader` overlays that report to a debug store). Then it takes a screenshot of each window with `screencapture -l <windowNumber> build/windows/<name>.png` and quits with exit code 1 if any size is more than 2 pt off the table, any minimum is wrong, any window is off-screen, or any view is clipped. Run this after every UI change and open the PNGs before moving on. A window that "looks about right" in the log is not done until the screenshot has been looked at.
### 1. Onboarding (first launch only)
A window with three steps and a progress row at the top.
- **Microphone**: request with `AVCaptureDevice.requestAccess(for: .audio)`. Show granted or denied, with a button to the Microphone privacy pane if denied.
- **Accessibility**: call `AXIsProcessTrustedWithOptions` with the prompt option, plus a button that opens `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility`. Poll `AXIsProcessTrusted()` every second so the checkmark flips without a restart.
- **Speech model**: one paragraph that says what will be downloaded (Parakeet TDT 0.6B v3, about 600 MB, from Hugging Face), where it goes (`modelPath`), and that this is the only time the app will ever use the network. A "Download" button with a progress bar and a "Load from folder…" button for machines that can't reach the internet. When the download finishes, show the checksum and a "Cleanup" line that says which backend was auto-detected ("Apple Intelligence found", "Ollama found with qwen3:4b", or "No local model found. Using rules. Install Ollama to do better." with a link to `https://ollama.com`).
- Last step shows the hotkey and a plain text field labeled "Try it here".
Show this window again from the menu ("Check permissions…") if either permission is missing at launch, or if the model folder is missing.
### 2. Menu bar item
- Icon states: idle (outline mic), listening (filled mic, pulsing with a 1 s animation), processing (indeterminate spinner), error (mic with a small badge for 5 seconds).
- Menu items, top to bottom: the hotkey hint ("Hold ⌥ (right) to dictate"), a disabled line with the last dictation's timings ("Last: 0.4 s speech, 0.9 s cleanup"), a separator, "Cleanup" submenu (Apple / Ollama / Rules / Off, with a checkmark and the unavailable ones disabled), a separator, History…, Dictionary…, Settings…, Check permissions…, a separator, Quit.
- Clicking the icon with Option held starts a dictation in toggle mode regardless of `hotkeyMode`. Useful when the hotkey is broken.
### 3. Overlay
A small pill at the bottom center of the screen that has the frontmost app's window.
- `NSPanel` subclass with `.nonactivatingPanel` in the style mask, `level = .floating`, `hidesOnDeactivate = false`, `collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]`, `isMovableByWindowBackground = false`, `ignoresMouseEvents = true`.
- Override `canBecomeKey` and `canBecomeMain` to return false. The target app keeps keyboard focus the entire time. This is the single most important detail in the UI.
- Contents while listening: a 12-bar level meter driven by RMS, then the interim transcript in one line, truncated at the front so the newest words stay visible.
- After key up: "Transcribing…", then "Cleaning…", then the final text for 800 ms with a checkmark, then hide. On error: the error message in red for 3 seconds.
- Size: 420 pt wide max, grows with text up to 3 lines.
### 4. History window
- A list of dictations, newest first, grouped by local day ("Today", "Yesterday", then "Tuesday, Sep 8").
- Each row: app icon (from `NSWorkspace.shared.icon(forFile:)` on the app's bundle path, cached), app name, local time, duration, word count, and the text.
- A segmented control at the top, **Raw | Cleaned**, switches every row between `raw_text` and `cleaned_text`. Persist the choice in `historyShowsRaw`. Rows where the two are identical show a small "no changes" tag. Rows with null text (transcript storage off) show "Not stored".
- Per row actions: Copy, Insert again (runs the inserter against the current frontmost app after a 1 s delay so the user can switch windows), Delete. Right-click a word: "Add '<word>' to dictionary".
- Search box filters on both raw and cleaned text, case-insensitive.
- Rows with an error show it inline in a muted color, e.g. "Cleanup timed out. Inserted raw text."
- Footer: count of rows and total words this week.
### 5. Dictionary window
- Table with columns `term`, `sounds like`, `uses`. Add, edit inline, delete.
- Import and export a plain text file: one term per line, optional `term | sounds like`. Import skips duplicates and reports how many were added.
- Show a count. Every entry goes to the cleanup prompt; the prompt caps at 200 entries, longest first.
### 6. Settings window
Tabs:
- **General**: hotkey recorder, hold vs toggle mode, trailing space, sounds, launch at login (`SMAppService.mainApp`). The recorder uses `NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged])` while the Settings window is key and must handle two shapes: a non-modifier `keyDown` is recorded immediately with whatever modifiers are down (`⌃⌥ Space`), and a single modifier pressed and released with no key in between becomes a modifier-only hotkey (`⌥ (right)`). Do not finish on the first `flagsChanged` event, or `⌃⌥Space` gets saved as `⌃ (left)` before the user reaches the Space. Show the modifiers live while they're held, Escape cancels, and there's a Cancel button. Below the field, one line explains the two shapes and that a lone modifier has to be held 120 ms before it counts.
- **Speech**: the model name and version, `modelPath` with a "Reveal in Finder" button, the checksum in monospace with a copy button, the compile status ("Compiled, loads in 0.8 s"), a "Re-download" button, and "Load from folder…". A "Test" button transcribes `fixtures/hello_world.wav` and shows the text and the time it took.
- **Cleanup**: backend picker (Apple / Ollama / Rules / Off). Each option shows its availability and a reason when it's greyed out: "Needs macOS 26 with Apple Intelligence on", "Ollama isn't running. Install it from ollama.com and run `ollama pull qwen3:4b`." Ollama model name (free text with suggestions `qwen3:4b`, `gemma3:4b`, `llama3.2:3b`) and a "Pull" button that runs the pull through Ollama's API with progress. Default tone. A "Test" button that runs `fixtures/transcripts/sample.txt` through the active cleaner and shows before and after with the round-trip time.
- **Apps**: table of `app_styles` with app name, tone, format, hint. "Add current app" reads the frontmost app's bundle id and name. Tone `raw` means skip cleanup for that app. Delete removes the row (the `*` row can't be deleted).
- **Privacy**: store transcripts on/off, retention days, "Delete all history", and a plain statement of the guarantee: "Audio and text never leave this Mac. The app made one network request, on <date>, to download the speech model. With Ollama as the cleaner, text goes to a local process on 127.0.0.1." Show the date of the download from `UserDefaults`.
## The dictation pipeline
Put this in `FlowCore` as `DictationPipeline`, with injected protocols so it can be tested without a mic, a model, or Accessibility permission:
```swift
protocol AudioSource { func start() throws -> AsyncStream<[Float]>; func stop() }
protocol Transcriber {
func transcribe(audio: AsyncStream<[Float]>) -> AsyncThrowingStream<TranscriptEvent, Error>
}
enum TranscriptEvent { case interim(String), final(String) }
struct CleanupContext { let tone: String; let hint: String; let format: String; let dictionary: [DictionaryEntry] }
protocol Cleaner { var backend: CleanupBackend { get }; func clean(_ raw: String, context: CleanupContext) async throws -> String }
enum CleanupBackend: String { case apple, ollama, rules, off }
enum InsertResult { case ax, paste, clipboardOnly, failed(String) }
protocol TextInserter { func insert(_ text: String) async -> InsertResult }
protocol FrontmostApp { var bundleId: String? { get }; var name: String? { get } }
protocol Clock { func now() -> Date; func sleep(ms: Int) async }
```
State machine: `idle → listening → finalizing → cleaning → inserting → idle`, plus `cancelled` reachable from `listening` and `finalizing`.
1. **Key down** (hold mode) or first tap (toggle mode): read the frontmost app, start audio, open the transcriber stream, show the overlay, play the start tick. If the key is released within 250 ms in hold mode, treat it as an accidental tap and cancel silently with no history row.
2. **While listening**: forward 100 ms audio chunks to the transcriber. Show interim text in the overlay. Cap a single dictation at 5 minutes; at the cap, act as if the key was released. The cap is a task that sleeps and then calls the key-up path; that path cancels the cap task, so hop to a fresh unstructured `Task` before calling it or the 3 second final-transcript wait inherits the cancellation and times out instantly. Escape cancels: stop audio, close the stream, hide the overlay, write a `cancelled` row with null text, insert nothing.
3. **Key up** (or second tap): stop audio, tell the transcriber the stream is done, wait up to 3 seconds for the `final` event. If the result is empty or whitespace, hide the overlay and write nothing.
4. **Snippets**: case-insensitive whole-phrase match of each `trigger_phrase` in the raw text (word boundaries on both sides); replace with `expansion`. Do this before cleanup so the model sees the expansion.
5. **Cleanup**: if `cleanupBackend` isn't `off` and the app's tone isn't `raw`, call the cleaner with a 4 second timeout. On any error, timeout, or guard failure, use the raw text and store the reason in `error` (`cleanup_error`, `cleanup_timeout`, `cleanup_guard`). Never block insertion for longer than the timeout. If the Apple or Ollama cleaner throws because it's unavailable, retry once with `RulesCleaner` before falling back to raw, and record `cleanup_backend = rules` on the row.
- The timeout must not wait for the loser. A `withThrowingTaskGroup` race looks right but the group awaits every child before the scope returns, so a model call that ignores cancellation holds insertion past the deadline (the first build of this app took 7.8 s on a 4 s timeout with the Apple model). Implement `withTimeout` with two unstructured `Task`s and a continuation that resumes exactly once from whichever finishes first; cancel the loser and abandon it. Test it with a fake cleaner that parks on a never-resumed continuation.
- Escape during cleanup skips the cleaner instead of cancelling: the words are already spoken. Cancel the cleanup task, insert the raw transcript right away, and store `error = "cleanup_skipped"`. The overlay says "Cleaning… (Esc to skip)".
- With the Apple model, call `LanguageModelSession.prewarm()` once at launch so the first cleanup of the session isn't a cold load.
6. **Insert**: call the inserter. Append a trailing space when `trailingSpace` is on and the text doesn't end in a newline. Increment `use_count` on every dictionary term that appears in the inserted text.
7. **Record**: write the `dictations` row. If `storeTranscripts` is off, leave the three text columns null but keep timings and word count.
Timings to hit on an M1 or newer: final transcript within 700 ms of key up for a 15 second dictation, cleanup under 1.5 s with the Apple model or a 4B Ollama model, insert under 200 ms. Log all three per dictation.
If the hotkey is pressed while the pipeline is anywhere but `idle`, ignore it.
## Hotkey
- Use a `CGEvent` tap: `CGEvent.tapCreate` on `.cgSessionEventTap` at `.headInsertEventTap`, listening to `.keyDown`, `.keyUp`, and `.flagsChanged`. This needs the Accessibility permission, which you already require.
- Run the tap on a dedicated background thread with its own `CFRunLoop`, never the main run loop. This is the single most dangerous part of the app: an active `.headInsertEventTap` sits in front of every keystroke on the machine, and the system waits for your callback to return before the keystroke reaches any app. If the callback is starved, the whole keyboard freezes until the process is killed. On the main run loop, any main-thread stall (SwiftUI layout, the model load, a long continuation, a spinning beachball) does exactly that. The first build of this app put the source on `CFRunLoopGetMain()` and locked up the user's keyboard mid-session. So: create the tap, add its source to a private `Thread`'s run loop (`qualityOfService = .userInteractive`), and keep the callback to microseconds — take the matcher decision under a short lock, then hand every side effect to `DispatchQueue.main.async`. Never `await`, never call the pipeline, and never hop to the main actor synchronously from inside the callback. Re-enable on `.tapDisabledByTimeout` / `.tapDisabledByUserInput` as before.
- Tag the synthetic Cmd+V you post from the inserter (`setIntegerValueField(.eventSourceUserData, value: magic)`) and, at the very top of the tap callback, pass through any event whose `.eventSourceUserData` is that magic. Otherwise the paste keystroke feeds back through your own tap; harmless with a modifier-only hotkey but a stuck-modifier bug with a chord.
- Support modifier-only hotkeys and regular keys with modifiers. Default is Right Option (`flagsChanged` with key code 61). Also handle Fn (`.maskSecondaryFn`), Right Command (key code 54), and any key plus modifiers.
- A modifier-only hotkey collides with every ⌥-combo the user types (⌥←, ⌥-Delete, accented characters), so "flag appears" must not start anything. Run a three-phase state machine, `idle → armed → held`: the flag appearing with no other modifier held (`flags & relevantMask == thatBit` exactly) arms and starts a 120 ms timer; any `keyDown` or other `flagsChanged` inside that window, or the flag disappearing, disarms with zero side effects (no mic, no overlay, no sound, no row); the timer firing while still armed is the real "down". After that, "up" is the flag disappearing, and any other key while held means it was a shortcut after all: cancel silently and pass the event through. Put this logic in `FlowCore` as a pure `HotkeyMatcher` with `handle(kind:keyCode:flags:dictating:) -> HotkeyAction` and `armTimerFired()`, and unit test the ⌥-combo, the quick tap, and the shift-already-held cases. The first version of this app fired on the flag alone and every ⌥-shortcut flashed the overlay and opened the mic.
- Never swallow modifier events. Return the event unchanged for `flagsChanged` even when it's the hotkey; a modifier press alone does nothing in any app, and swallowing it leaves apps that track modifier state from `flagsChanged` with the wrong idea of what's held. Only a chord hotkey (a real key plus modifiers) swallows its own `keyDown`/`keyUp`; Escape while dictating is swallowed too.
- Re-enable the tap when macOS disables it: handle `.tapDisabledByTimeout` and `.tapDisabledByUserInput` by calling `CGEvent.tapEnable` again.
- If the tap can't be created (permission missing), show the onboarding window and set the icon's error state.
## Audio
All of this runs locally in `FlowCore/Audio/` with AVFoundation and Accelerate, before any sample reaches Parakeet.
- `AVAudioEngine` input node tap with the hardware format, converted to 16 kHz mono `Float32` with `AVAudioConverter`. Parakeet expects 16 kHz mono floats in the -1 to 1 range.
- Emit chunks of 1,600 samples (100 ms). Compute RMS per chunk with `vDSP_rmsqv` for the level meter.
- `GainNormalizer`: on key up, compute the peak of the whole buffer with `vDSP_maxmgv` and scale so the peak is -3 dBFS, but never amplify by more than 20 dB (a silent room shouldn't become a hiss). Apply with `vDSP_vsmul`.
- `SilenceTrimmer`: run FluidAudio's `VadManager` over the buffer on key up and keep the span from 200 ms before the first speech segment to 200 ms after the last one. If VAD finds no speech, the transcript is empty and nothing is inserted. Skip VAD on the interim passes; it's only for the final.
- If the default input device changes mid-dictation, keep going with whatever the engine gives you and log it. If the engine throws on start, fail the dictation with "Couldn't open the microphone" and set the error icon.
- Play two short sounds (start and stop) with `NSSound` at 30% volume when `playSounds` is on. Ship them as small AIFF files in `Resources/`.
## Transcriber
One transcriber, `ParakeetTranscriber`, in `FlowCore/Transcribers/`. It wraps FluidAudio.
### Model files
- The model is `FluidInference/parakeet-tdt-0.6b-v3-coreml` on Hugging Face: the encoder, decoder, joint, and preprocessor `.mlmodelc` packages plus the vocabulary. About 600 MB.
- `ModelDownloader` (in `FlowCore/Models/`) fetches the repo's file list, downloads each file into `modelPath` with resumable `URLSessionDownloadTask`s and a progress callback, then computes a SHA-256 over every file (sorted by path, hash of the concatenated per-file hashes) and stores it as `modelChecksum`. Write `manifest.json` next to the files with the file names, sizes, per-file hashes, and the download date.
- Check FluidAudio's `AsrModels` for a way to load from a custom directory. If it has one, use it with `modelPath`. If it only loads from its own cache (`~/.cache/fluidaudio/Models/`), symlink or copy `modelPath` into that location on load and say so in the README. Don't let the library download on its own; the app owns the one network request.
- "Load from folder…" copies a folder the user picks into `modelPath`, validates that the expected files are present, computes the same checksum, and writes the manifest with `source = "folder"`.
- Compile happens on first load and takes 10 to 30 seconds. Show "Compiling model…" in the overlay and the menu bar if a dictation starts before it's done. Cache the compiled model so every later launch loads in about a second.
- Load the model once, on launch, on a background task. Keep it in memory for the life of the process.
### Inference
- **Interim pass**: every 1,000 ms while listening, run `AsrManager.transcribe` over the whole buffer so far (up to the last 30 seconds) on a serial background queue. Emit the result as `.interim`. If a pass takes longer than 1,000 ms, skip the next tick instead of queueing. FluidAudio's `SlidingWindowAsrManager` is the streaming alternative; use it only if its partial text is stable between passes.
- **Final pass**: on key up, trim silence, normalize gain, run `transcribe` once over the full buffer, and emit `.final`. Parakeet TDT returns punctuation and capitalization on its own. Keep them; the cleaner does the rest.
- Language: Parakeet v3 detects the language itself among its 25. Don't add a language picker.
- If the model folder is missing or fails to load, fail with "No speech model. Download one in Settings → Speech" and a notification that opens Settings.
- Must work with no network at all. There's no code path in this file that can open a socket.
### If FluidAudio doesn't fit
If FluidAudio's API has moved, or its CoreML build doesn't run on the target machine, swap the implementation for `sherpa-onnx` with its Parakeet TDT 0.6B v3 int8 ONNX export and the sherpa-onnx Swift bindings. Keep the `Transcriber` protocol identical so nothing else changes. Note the swap in the README.
## Cleaners
Three `Cleaner` implementations behind one `CleanerFactory` that picks by `cleanupBackend` and availability. The prompt is the same for all of them.
### AppleCleaner (macOS 26, Foundation Models)
- Lives in the `Flow` target because it imports `FoundationModels`. Wrapped in `#available(macOS 26, *)`.
- Available when `SystemLanguageModel.default.availability == .available`. Otherwise report the reason (`.deviceNotEligible`, `.appleIntelligenceNotEnabled`, `.modelNotReady`) in Settings and don't offer it.
- One `LanguageModelSession(instructions:)` per cleanup with the system prompt below as the instructions and the raw transcript as the prompt. Use `respond(to:)` with `GenerationOptions(temperature: 0)`. Set `maximumResponseTokens` to `min(4096, rawTokens * 2 + 64)` where `rawTokens` is `rawCharacters / 4`.
- If the model refuses because of its safety guardrails (a `guardrailViolation` error), fall back to the rules cleaner for that dictation. Don't retry.
### OllamaCleaner (macOS 15, or Apple Intelligence off)
- `POST http://127.0.0.1:11434/api/chat` with `model = ollamaModel`, `stream: false`, `messages` = system prompt plus the raw transcript, `options: { temperature: 0, num_predict: <same cap as above> }`, and `think: false` for models that support it (qwen3 will otherwise spend seconds thinking).
- Availability check: `GET /api/tags` with a 500 ms timeout on launch and whenever Settings opens. If it fails, mark the backend unavailable with the one-line hint "Ollama isn't running. Install it from ollama.com and run `ollama pull qwen3:4b`." If the server is up but `ollamaModel` isn't in the tag list, the hint says which model to pull.
- Refuse any `ollamaBaseURL` whose host isn't `127.0.0.1`, `localhost`, or `::1`. This is the only place the app talks to a socket after the model download, and it must stay on the loopback interface.
### RulesCleaner (always available, no model)
In `FlowCore/Cleaners/RulesCleaner.swift`, pure Swift:
1. Replace each dictionary `sounds_like` with its `term`, case-insensitive, whole phrase.
2. Remove filler tokens from a list in `Prompts.swift`: `um`, `uh`, `umm`, `uhh`, `er`, `ah`, `hmm`, `you know`, `I mean`, `sort of`, `kind of`, and `like` or `so` only at the start of a sentence. Match on word boundaries.
3. Apply the self-correction pattern `X, no, Y` and `X no Y` when `no` sits between two short phrases of the same shape (a weekday, a number, a single capitalized word): keep `Y`.
4. Spoken punctuation: `period`, `comma`, `question mark`, `new line`, `new paragraph` become the symbol when they end a clause.
5. Collapse whitespace, capitalize the first letter of each sentence, add a terminal period if the text ends in a letter or digit and `tone` isn't `casual`.
6. If `format` is `markdown` and the text contains `first`, `second`, `third` or `one`, `two`, `three` as clause starters, split them into a `- ` list.
It won't match a model, but it makes the app usable with nothing installed, and it's what the tests run against for determinism.
### The prompt
System prompt (put it in `Prompts.swift` and keep exactly this shape so tests can assert on it):
```
You clean up dictated speech into written text. Rules:
- Remove filler words (um, uh, like, you know, so at the start of a sentence) and false starts.
- Apply self-corrections: "send it Tuesday, no, Wednesday" becomes "send it Wednesday".
- Add punctuation and capitalization. Split into sentences and paragraphs where the speaker clearly paused or changed topic.
- When the speaker lists items ("three things", "first... second..."), format a list if FORMAT is markdown, otherwise keep it as a sentence.
- Spoken punctuation ("period", "comma", "new line", "new paragraph") becomes the symbol only when it clearly isn't part of the sentence.
- Format emails, URLs, numbers, and times the way a person would type them.
- Keep the speaker's words and meaning. Do not add, summarize, answer, or explain. Do not translate.
- Output only the cleaned text. No quotes, no preamble, no markdown fences.
TONE: {tone}. {hint}
FORMAT: {format}
DICTIONARY (spell these exactly as written when the speaker says them): {term (sounds like: sounds_like)}, ...
```
The user message is the raw transcript. If the dictionary is empty, omit the DICTIONARY line entirely. Entries without `sounds_like` are listed as just the term.
Guards after the response, for every backend:
- Trim whitespace. Strip one pair of wrapping quotes or a code fence if the model added one. Strip a `<think>…</think>` block if a model emitted one anyway.
- If the raw text has more than 5 words and the cleaned word count is under 40% or over 200% of the raw word count, reject and fall back to raw with `error = "cleanup_guard"`.
- If the response is empty, fall back to raw.
- If the response starts with "I'm sorry" or "I can't" and the raw text doesn't, fall back to raw. Models sometimes answer instead of clean.
Counting words: split on whitespace. Good enough.
## Text inserter
Run every Accessibility and pasteboard call on the main thread. `AXUIElementSetAttributeValue`, the pasteboard writes, and the Cmd+V post must all happen on `@MainActor`; the pipeline calls the inserter with `await`, so isolate the inserter to the main actor and the hop happens for free. This is not optional politeness: when the focused element belongs to your own process — the onboarding "Try it here" field is the obvious case, and the frontmost bundle id will be your own — the AX set runs the target's AppKit text editing in-process on the calling thread, and AppKit's text layer asserts it is on the main queue and traps (`dispatch_assert_queue_fail`, EXC_BREAKPOINT). The first build of this app ran the inserter on the pipeline actor's background thread and crashed the moment someone dictated into its own text field. Cross-process AX writes are also officially main-thread-only, so this is the right rule everywhere, not just for the self-insert case.
`AXInserter` in the `Flow` target, since it needs AppKit and `ApplicationServices`.
1. Get the system-wide element's `kAXFocusedUIElementAttribute`. If there's no focused element at all, go to step 4.
2. If the element's role is `AXTextField`, `AXTextArea`, `AXComboBox`, or it reports `AXFocused = true` and `kAXSelectedTextAttribute` is settable (`AXUIElementIsAttributeSettable`), try `AXUIElementSetAttributeValue(element, kAXSelectedTextAttribute, text)`. Then read back `kAXValueAttribute`; if the value now contains `text`, return `.ax`. Many browser and Electron text fields report success without inserting, which is why you read back. If the read-back fails or the value is huge (over 200 KB), don't compare, just go to step 3.
3. Clipboard paste. Snapshot every item and every type on `NSPasteboard.general`. Write the text as a single string item. Post Cmd+V as `CGEvent` key down and key up (key code 9 with `.maskCommand`) to `.cghidEventTap`. Wait 150 ms. Restore the snapshot. Return `.paste`. If restoring throws, leave the text on the clipboard rather than leaving it empty.
4. No focused element (Finder, an empty desktop, a video player): don't post Cmd+V. Write the text to the clipboard and leave it there. Send a `UNUserNotification` saying "No text field was focused. The text is on your clipboard." Return `.clipboardOnly`.
Rules:
- Secure text fields (`AXSecureTextField`): never insert. Return `.failed("secure field")`, notify, and don't store the text in history.
- Terminals: paste is the right path. Multi-line text into a terminal will run lines as commands, so when the frontmost app is a terminal and the text has more than one line, join lines with spaces first.
- Restore the clipboard even if the paste keystroke fails. Use `defer`.
## Notifications and logging
- Use `UNUserNotificationCenter` for the four user-facing cases: no text field, secure field, missing model, cleanup backend went away. Ask for notification permission the first time you need to send one instead of at launch.
- Log to `~/Library/Logs/Flow/flow.log` with `os.Logger` mirrored to the file. One line per pipeline stage with timings. Never log transcript text unless `FLOW_DEBUG=1` is set in the environment, and say so in the README.
- Rotate the log at 5 MB, keep two.
## History retention
On launch and once a day, delete `dictations` rows older than `historyRetentionDays` when it's above 0. "Delete all history" truncates the table and runs `VACUUM`.
## Non-goals
Do not build: a Windows or Linux version (keep `FlowCore` free of AppKit so a Windows port can reuse it later), iOS or Android keyboards, a meeting notetaker, team accounts, shared dictionaries, SSO, billing, auto-updates, or command mode that edits selected text. Leave a `// TODO: command mode` where the pipeline would branch on a second hotkey. No cloud APIs of any kind: no Deepgram, no OpenAI, no OpenRouter, no hosted anything, even as an option behind a setting. No API keys, no Keychain, no accounts, no Stripe, no server of your own. No telemetry and no crash reporter.
## Acceptance criteria
1. On first launch the app appears in the menu bar with no Dock icon, and the onboarding window walks through Microphone, Accessibility, and Speech model. The Accessibility checkmark flips within 2 seconds of granting it in System Settings, without restarting.
2. After the model download, Settings → Speech shows a SHA-256 checksum that matches `manifest.json`, and "Load from folder…" with a copy of the same folder produces the same checksum.
3. Holding the hotkey in TextEdit, saying a sentence, and releasing inserts the text at the caret within 2 seconds of release on an M1 or newer. TextEdit stays the frontmost app the whole time.
4. While holding the key, interim words appear in the overlay within 1.5 seconds of being spoken. The overlay never becomes the key window (`NSApp.keyWindow` stays nil and `NSWorkspace.shared.frontmostApplication` is unchanged).
5. `fixtures/hello_world.wav` through `ParakeetTranscriber` yields a transcript containing `hello world`. `fixtures/silence.wav` (3 seconds of room noise) yields an empty transcript and no history row.
6. Raw transcript `um so send it uh Tuesday no Wednesday` becomes `So send it Wednesday.` with the default style, through both the rules cleaner and whichever model cleaner is available.
7. With `Wolchonok` (sounds like `wall chunk`) in the dictionary, the cleanup prompt's DICTIONARY line contains `Wolchonok (sounds like: wall chunk)`, and the rules cleaner turns `call wall chunk back` into `Call Wolchonok back.`
8. The same raw transcript produces a prompt with `TONE: casual` when Slack is frontmost and `TONE: formal` when Mail is frontmost. With `format = markdown`, a spoken list of three items becomes a three-line markdown list; with `plain`, it stays one sentence.
9. If the cleanup call throws, times out at 4 seconds, or trips the length guard, the raw transcript is inserted, `error` is set on the history row, and the app doesn't crash or hang. If Ollama is quit mid-session, the next dictation uses the rules cleaner and records `cleanup_backend = rules`.
10. With all network blocked after the model download (Wi-Fi off, or a firewall rule that drops everything except loopback), dictation and cleanup still work.
11. During a dictation the app opens no sockets to any host other than 127.0.0.1. Verified with `nettop -p Flow` or `lsof -i -a -p <pid>`: with the Apple or rules cleaner the list is empty, with Ollama it shows exactly one loopback connection.
12. After a clipboard paste, the previous clipboard contents are restored within 500 ms, including an image item.
13. With Finder frontmost and no text field focused, nothing is typed, the text is on the clipboard, `insert_method = clipboard_only`, and a notification appears.
14. Pressing Escape while holding the hotkey inserts nothing and writes a `cancelled` row with null text. A dictation that returns an empty transcript inserts nothing and writes no row. The History window's Raw | Cleaned toggle switches every row, search filters on both columns, and Delete removes a row.
15. `Flow --check-windows` exits 0: every window opens within 2 pt of the size in the Window sizes table, respects its minimum, is fully on-screen, reports `clipped=0`, and the screenshots in `build/windows/` show no cut-off text at the default and one-step-larger text sizes.
## Deliverables
- The Swift package with `Flow` and `FlowCore` targets, `scripts/bundle.sh`, `Makefile` (including `make check-windows`), sounds, and an app icon.
- `build/windows/*.png` from the last `--check-windows` run, committed alongside the code so a reviewer can see each window without building.
- `FlowCoreTests` covering the pipeline state machine, snippet expansion, prompt building, the cleanup guards, the rules cleaner, the gain normalizer and silence trimmer, and the checksum, all with fakes. Two things the fakes get wrong the first time: a manual clock registers a sleep only when the sleeping task actually runs, so a test must wait for the sleeper to appear before advancing time (give the clock a `waitForSleeper(ms:)` helper) or the deadline is computed after the jump and never fires; and a progress callback that hops to a `Task` per call loses ordering, so collect callbacks synchronously behind a lock. Point the file logger at a temp directory when `XCTestConfigurationFilePath` is set so the suite doesn't fill `~/Library/Logs/Flow/flow.log` with fake dictations. Plus one test that scans `Sources/` and fails if any file other than `ModelDownloader.swift` and `OllamaCleaner.swift` mentions `URLSession`, `Network`, `NWConnection`, or `CFStream`.
- A `fixtures/` folder with `hello_world.wav` (3 seconds, 16 kHz mono), `silence.wav`, `parakeet/hello_world.json` (the recorded Parakeet output for that WAV: text, per-token timestamps, and the inference time on the machine it was recorded on), and three raw transcripts with expected cleaned output.
- README with: the Requirements section above, build and run steps, how to grant and reset the permissions (`tccutil reset Accessibility com.yourname.flow`, `tccutil reset Microphone com.yourname.flow`), the model download and the "Load from folder" path for offline Macs, how to install Ollama and pull a model, the default hotkey and how to change it, the `FLOW_DEBUG` flag, how to verify the no-network claim with `nettop`, and a short note on what a Windows port would need (a `SendInput` inserter, a `RegisterHotKey` or low-level keyboard hook, WASAPI capture, and sherpa-onnx in place of CoreML, with `FlowCore` reused as is).
Build `FlowCore` and its tests first (pipeline, prompt, rules cleaner, guards, audio processing) with fakes for every protocol. Then the model downloader and the Parakeet transcriber, tested against the real model on `fixtures/hello_world.wav`. Then the inserter and hotkey. Then the overlay and menu bar. Then the Apple and Ollama cleaners. Then History, Dictionary, and Settings. Run `make run` after each step and dictate into TextEdit with Wi-Fi off before moving on.06
Where to build it
Any of these will take the prompt. Use the one you already pay for.
- Claude CodeAgentic coding in your terminal
Best fit and honestly the only good fit. It runs `swift build` and `swift test` in your terminal, can read the Accessibility API errors, and can assemble the .app bundle. Native Mac work needs a local agent.
- ChatGPT / CodexPlan, then build with Codex
Use ChatGPT to rewrite the per-app style hints for the apps you actually use, and to tune the rule-based cleaner's filler list, then hand the spec to Codex or Claude Code.
- ReplitBuild and host in the browser
Skip it. Replit can't build or run a macOS menu bar app, and it can't load a CoreML model.
- LovablePrompt-to-app with Supabase built in
Skip it. Lovable builds web apps on Supabase. There's no web, no database server, and no browser anywhere in this project.
07
Test it
Walk the checklist by hand once, then let the agent write the automated tests.
Manual checklist
- Launch the app and confirm it shows in the menu bar with no Dock icon.
- Deny the Accessibility permission, then confirm the app tells you what to enable and links to the right System Settings pane.
- Turn off Wi-Fi before your first dictation after the model download finishes, confirm it works, and confirm Settings shows the model's checksum.
- Run `nettop` or Little Snitch during a dictation and confirm the app opens zero outbound connections. With Ollama as the cleaner, the only connection is to 127.0.0.1.
- Hold the hotkey in TextEdit, say a sentence, release, and confirm the text appears at the caret within two seconds.
- Watch the overlay while holding the key and confirm words appear as you speak and TextEdit stays the active app.
- Say 'um so send it uh Tuesday no Wednesday' and confirm you get 'So send it Wednesday.'
- Say 'three things: milk, eggs, and bread' and confirm you get a list in Notes and a single line in Terminal.
- Add your last name to the dictionary, say it, and confirm it's spelled right.
- Dictate into Slack, then into Mail, and confirm the Slack text is more casual.
- Copy an image, dictate into a text field, then paste and confirm the image is still on your clipboard.
- Dictate with Finder in front and confirm nothing is typed, the text is on the clipboard, and a notification says so.
- Press Escape while holding the hotkey and confirm nothing is inserted and no history row appears.
Generate automated tests
Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.
Write automated tests for the fully local macOS dictation app in this repo. Treat the acceptance criteria below as the spec. Use XCTest through `swift test` for everything in `FlowCore`, with fakes for `AudioSource`, `Transcriber`, `Cleaner`, `TextInserter`, `FrontmostApp`, and `Clock`. The real Parakeet model and the real cleaners are only touched by tests that skip when they're missing. Add a small XCUITest target for the flows that need a real app, and gate those behind `FLOW_INTEGRATION=1` since they need Accessibility and Microphone permissions that CI can't grant.
## Acceptance criteria to cover
1. First launch with no permissions shows the onboarding window, and the app registers a status item with `NSApp.activationPolicy() == .accessory` (integration).
2. `ModelChecksum.compute(folder:)` over `fixtures/fake_model/` (three small files) returns the value stored in its `manifest.json`, is stable across two runs, and changes when one byte of one file changes. `ModelImporter.load(from:)` on a copy of that folder writes a manifest with `source = "folder"` and the same checksum.
3. A full pipeline run with a fake transcriber that emits two interims and one final, a fake cleaner, and a fake inserter ends in `idle`, calls `insert` once with the cleaned text plus a trailing space, and writes one `dictations` row with `insert_method = ax` and `cleanup_backend` equal to the fake cleaner's backend.
4. Overlay panel: `styleMask` contains `.nonactivatingPanel`, `canBecomeKey` is false, `level == .floating`, `hidesOnDeactivate` is false (unit test on the panel subclass). Interim events from the fake transcriber reach the overlay view model in order.
5. `ParakeetTranscriber` with `modelPath` pointing at a downloaded model (skip with `XCTSkip` if the folder is missing) turns `fixtures/hello_world.wav` into a transcript containing `hello world`, and `fixtures/silence.wav` into an empty string. Compare the `hello_world` result against `fixtures/parakeet/hello_world.json` and print both if they differ. `SilenceTrimmer` on a buffer of 1 s silence, 2 s tone, 1 s silence returns a span between 1.8 s and 2.4 s long. `GainNormalizer` on a buffer peaking at 0.1 returns a peak within 0.01 of -3 dBFS and never amplifies a buffer peaking at 0.001 by more than 20 dB.
6. Prompt fixture: raw `um so send it uh Tuesday no Wednesday` through `RulesCleaner` produces exactly `So send it Wednesday.` The same input through `OllamaCleaner` against a stubbed `URLProtocol` that returns that string produces it too, and the request body has `temperature: 0`, `stream: false`, and a system message that starts with `You clean up dictated speech`. A `<think>…</think>` block in the stubbed response is stripped.
7. With dictionary entry `Wolchonok` / `wall chunk`: the built prompt contains `Wolchonok (sounds like: wall chunk)`, and `RulesCleaner` turns `call wall chunk back` into `Call Wolchonok back.` With an empty dictionary the prompt has no `DICTIONARY` line.
8. `PromptBuilder` with a `FrontmostApp` fake returning `com.tinyspeck.slackmacgap` yields `TONE: casual`; `com.apple.mail` yields `TONE: formal`; an unknown bundle id falls back to the `*` row; `com.apple.Notes` yields `FORMAT: markdown`, and `RulesCleaner` with that context turns `three things first milk second eggs third bread` into three `- ` lines.
9. Cleaner failures: a cleaner that throws, a cleaner that never returns (advance the manual clock past 4 seconds), and a cleaner that returns a 3-word response for a 20-word input each result in the raw text being inserted and `error` set to `cleanup_error`, `cleanup_timeout`, or `cleanup_guard`. A cleaner that throws `CleanerError.unavailable` causes exactly one call to `RulesCleaner` and a row with `cleanup_backend = rules`. The suite must not actually wait 4 seconds.
10. Network isolation: install a `URLProtocol` that fails every request, run the full pipeline with the real `RulesCleaner` and the real `ParakeetTranscriber` (skip if the model is missing), and assert the protocol was never hit. Separately, a test reads every file under `Sources/` and fails if any file other than `ModelDownloader.swift` and `OllamaCleaner.swift` contains `URLSession`, `NWConnection`, `CFStream`, or `import Network`.
11. `OllamaCleaner` refuses a base URL of `http://example.com:11434` and `http://10.0.0.5:11434` with `CleanerError.notLoopback`, and accepts `http://127.0.0.1:11434`, `http://localhost:11434`, and `http://[::1]:11434`. The availability check against a stubbed `/api/tags` that lists only `gemma3:4b` reports `ollamaModel = qwen3:4b` as missing with a hint that names `qwen3:4b`.
12. Clipboard restore: put a string and a PNG on `NSPasteboard.general`, run the paste path against a fake key poster, and assert both items are back within 500 ms (integration, since it touches the real pasteboard).
13. Inserter with a fake AX layer reporting no focused element returns `.clipboardOnly`, posts no Cmd+V, and leaves the text on the pasteboard.
14. Escape during `listening` moves the pipeline to `idle`, calls `stop()` on the audio source, calls `insert` zero times, and writes a row with `insert_method = cancelled` and null text columns. A final transcript of `" "` results in no `insert` call and no history row. `HistoryStore`: insert three rows, the view model returns `raw_text` when `historyShowsRaw` is true and `cleaned_text` otherwise, `search("wednesday")` matches on either column, and `delete(id)` removes exactly one row.
15. Snippets: trigger `my calendar link` expands inside `send them my calendar link please` case-insensitively and doesn't expand `my calendar links`.
16. Hold mode accidental tap: key down then key up 200 ms later (manual clock) cancels with no insert and no history row. Key up at 300 ms proceeds normally.
17. A second hotkey press while the pipeline is in `cleaning` is ignored: the state doesn't change and no second audio stream starts.
18. Interim scheduling: with a fake clock and a fake model that takes 1,500 ms per pass, ticks at 0, 1,000, 2,000, and 3,000 ms produce exactly two inference calls (the 1,000 ms tick is skipped).
19. Hotkey arming: `HotkeyMatcher` with Right Option returns `.arm` on the flag appearing alone, `.down` only from `armTimerFired()`, and `.disarm` (never `.down`) when a `keyDown` such as ⌥← arrives before the timer, when the flag disappears before the timer, or when Shift is already held at press time. A late `armTimerFired()` after a disarm is `.passThrough`. `swallowsOwnEvents` is false for every modifier-only hotkey and true for `⌃⌥Space`.
20. Cleanup timeout without cooperation: a fake cleaner that parks on a never-resumed continuation (ignores cancellation) still results in the raw text being inserted with `error = cleanup_timeout` once the manual clock passes 4 seconds. The pipeline must not wait for that cleaner to return.
21. Escape during `cleaning` with the same uncancellable fake cleaner inserts the raw transcript immediately with `error = cleanup_skipped`, `insert_method = ax`, and the cleaner's backend on the row. The five-minute cap (manual clock advanced past 300,000 ms after the sleeper registers) ends the dictation with one insert and `duration_ms` at the cap.
23. Inserter isolation: `AXInserter` is `@MainActor`, so its methods are reached with `await`. In `FlowTests` (which run on the main actor), a fake AX layer whose focused element belongs to the current process is inserted into without crashing, proving the AX write happens on the main thread. Assert no `insert` path calls `AXUIElementSetAttributeValue` or a pasteboard write off the main thread (the compiler enforces this once the type is `@MainActor`; the test's job is to exercise the self-insert case that trapped the first build).
22. Window sizes: in `FlowTests`, open each window through the same code path the app uses and assert `contentRect` is within 2 pt of the table in the build prompt (Onboarding 560×480, History 820×560, Dictionary 520×440, Settings 560×520, overlay 420×44 idle), that `minSize` matches, that the frame is inside `NSScreen.main!.visibleFrame`, and that the clipped-view count from the debug store is 0. Also run `Flow --check-windows` as an integration step and assert exit code 0.
## Layout
- `Tests/FlowCoreTests/PipelineTests.swift`: 3, 9, 14, 16, 17, 18, 20, 21.
- `Tests/FlowCoreTests/HotkeyLogicTests.swift`: 19.
- `Tests/FlowCoreTests/PromptTests.swift`: 6, 7, 8.
- `Tests/FlowCoreTests/RulesCleanerTests.swift`: the rules halves of 6, 7, 8.
- `Tests/FlowCoreTests/OllamaCleanerTests.swift`: 6 (stubbed), 11.
- `Tests/FlowCoreTests/AudioTests.swift`: the trimmer and normalizer halves of 5.
- `Tests/FlowCoreTests/ModelTests.swift`: 2.
- `Tests/FlowCoreTests/ParakeetTests.swift`: 5. Skips without a model.
- `Tests/FlowCoreTests/NetworkIsolationTests.swift`: 10.
- `Tests/FlowCoreTests/HistoryStoreTests.swift`: 14, using an in-memory GRDB database.
- `Tests/FlowCoreTests/SnippetTests.swift`: 15.
- `Tests/FlowTests/` (needs AppKit): 4, 13, 22, 23.
- `Tests/FlowUITests/`: 1, 12. Gated on `FLOW_INTEGRATION=1`.
## Rules
- Name every test after its criterion: `func test_AC9_cleanupTimeoutFallsBackToRaw()`.
- Inject time. The pipeline takes the `Clock` protocol; tests use a manual clock so timeouts, the 250 ms tap threshold, and the 1 s interim tick fire instantly.
- Never open a real socket in tests. Every Ollama call goes through a `URLProtocol` stub. There is no cloud provider to mock, and a test that reaches for one is a bug in the test.
- Fakes live in `Tests/FlowCoreTests/Fakes/`, one file per protocol (`FakeTranscriber`, `FakeCleaner`, `FakeInserter`, and the rest), and record every call so tests can assert on call counts and arguments. `FakeCleaner` takes a `backend` so criterion 3 can check what gets written to the row.
- Add `make test` and a GitHub Actions workflow on `macos-15` that runs `swift build` and `swift test`. The model tests skip there because there's no model; the network isolation source scan runs everywhere. The UI tests don't run in CI; document how to run them locally.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass, fail, or skipped, and what changed.Wispr Flow is a trademark of Wispr AI, Inc.. This page is independent research and is not affiliated with or endorsed by Wispr AI, Inc.. Last updated 2026-09-10.