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.
Build it in any tool
Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.
Paste anywhere
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.
Claude Code
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."
Codex CLI
codex "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. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.
# 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, and an ad hoc signature that changes will make macOS ask again.
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
### 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 (captures the next key press, shows a symbol string like `⌥ (right)` or `⌃⌥ Space`), hold vs toggle mode, trailing space, sounds, launch at login (`SMAppService.mainApp`).
- **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. 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.
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.
- 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.
- For a modifier-only hotkey, "down" is the flag appearing and "up" is the flag disappearing. If any other key is pressed while the modifier is held, treat it as a normal shortcut: cancel the dictation silently (no row) and pass the events through. This is what makes Right Option safe as a default.
- Swallow the hotkey's own events (return nil from the callback) so the target app doesn't see them.
- 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
`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.
## Deliverables
- The Swift package with `Flow` and `FlowCore` targets, `scripts/bundle.sh`, `Makefile`, sounds, and an app icon.
- `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. 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).
## Layout
- `Tests/FlowCoreTests/PipelineTests.swift`: 3, 9, 14, 16, 17, 18.
- `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.
- `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.