Lowspeak is an open-source menu bar app for AI speech-to-text in every Mac app: hold fn, talk, let go, and the text appears at the cursor. It transcribes online with Groq (free on Groq's free tier with your own key) or OpenAI, or offline with Whisper and NVIDIA Parakeet, and it is written in Rust on AppKit through the objc2 crates. Here are seven places where macOS needed more care than expected, with the code that handles them.
1. fn only arrives if you ask for nothing else
To an event tap, the fn key is a modifier change with keycode 63 and the MaskSecondaryFn flag. The catch: as soon as an event tap also asks for key-down or key-up events, macOS stops delivering fn to it.
// Only flagsChanged: adding keyDown/keyUp makes macOS stop delivering fn events. let mask = 1u64 << CGEventType::FlagsChanged.0; CGEvent::tap_create( CGEventTapLocation::SessionEventTap, CGEventTapPlacement::HeadInsertEventTap, CGEventTapOptions::Default, mask, Some(callback), Arc::as_ptr(inner).cast_mut().cast(), ) Three more rules followed from it. The callback returns a null pointer for fn, so the event stops there. macOS disables a tap that answers too slowly, so the callback re-enables it on TapDisabledByTimeout, and a 30-second watchdog checks as well. And the callback never writes a file: an active tap delays every modifier event of the whole session, so its log lines go to a separate thread.
2. Open the connection while the user is still talking
A request after a pause pays for DNS, TCP and TLS before the first byte of audio moves. Lowspeak sends a HEAD request to the provider the moment recording starts, through the same shared URLSession the real request uses later:
/// Opens a connection to the URL's host for the next request and ignores the answer. pub fn warm_up(url: &str) { let Some(url) = NSURL::URLWithString(&NSString::from_str(url)) else { return; }; let request = NSMutableURLRequest::requestWithURL(&url); request.setHTTPMethod(ns_string!("HEAD")); request.setTimeoutInterval(10.0); let handler = RcBlock::new(|_: *mut NSData, _: *mut NSURLResponse, _: *mut NSError| {}); let task = unsafe { NSURLSession::sharedSession().dataTaskWithRequest_completionHandler(&request, &handler) }; task.resume(); } By the time fn is released, the connection is open. With Groq, the first dictation after a pause arrives about 0.4 seconds after letting go. Warm-ups are limited to one every 20 seconds per provider.
3. The system AAC encoder pads your file
Smaller uploads are faster uploads, so recordings go out as AAC in an M4A file. Two surprises: 48 kbit/s is the highest rate the system encoder accepts for 16 kHz mono (64 kbit/s makes writes fail with !dat), and ExtAudioFile reserves about 23 KB of free space in front of the audio. For a short dictation that is a large share of the file. A small MP4 atom walker removes the free atom and shifts the chunk offsets in stco or co64 to match.
4. Pasting without losing the user's clipboard
Dictation pastes through the clipboard and a simulated ⌘V. The hard part is giving the clipboard back. Instead of putting the string on the pasteboard, Lowspeak promises it through an NSPasteboardItemDataProvider, so it learns the moment the target app actually reads the text:
let item = NSPasteboardItem::new(); let promised = item.setDataProvider_forTypes( ProtocolObject::from_ref(&*provider), &NSArray::from_slice(&[string_type]), ); // org.nspasteboard.TransientType: clipboard managers that follow nspasteboard.org skip it. if !promised || !item.setData_forType(&NSData::new(), &transient) { return None; } The old content comes back 500 ms after the first read, or after 10 seconds if nobody reads. If another app changed the clipboard in the meantime (the change count tells), its content wins. Both ⌘V events are created before either is posted, so a failure can't leave a pressed key behind.
5. An LLM in the pipeline needs guard rails
Optional post-processing sends the transcript to gpt-oss-120b on Groq or gpt-4o-mini on OpenAI to remove filler words and fix grammar. A dictation can contain anything, including "ignore your instructions", so the transcript goes in as data between <transcript> tags, never as part of the prompt. The answer has to pass three checks: finish_reason must be stop, and the text must be neither empty nor implausibly long.
fn plausible(raw: &str, answer: String) -> Result<String, PostProcessError> { let limit = raw.chars().count() * MAX_GROWTH_FACTOR + LENGTH_SLACK; // 2× + 40 if answer.is_empty() || answer.chars().count() > limit { return Err(PostProcessError::Implausible); } Ok(answer) } If any check fails, the raw transcript is pasted. One more rule for terminals: trailing line breaks are stripped, so a dictation never runs as a command.
6. Testing chunk seams with macOS's own voices
Parakeet runs on the Neural Engine through a Rust port of FluidAudio's CoreML pipeline. Long recordings are cut into 15-second windows that overlap, and the words are merged at the seams. Seams are where words get lost or doubled, so an ignored-by-default test generates speech with say in two voices, Samantha (English) and Anna (German), places it 0, 3, 6 and 9 seconds into the recording so the words fall onto different seams, and repeats that clean, very quiet and with added noise. It then compares the expected and the recognised words with a longest-common-subsequence diff. Release 1.0.3 also fixed how Parakeet v2 counts its context frame, which had shifted words at the window edges.
7. Framework texts follow your Info.plist
Sparkle, the update framework, ships translations for dozens of languages. Yet its update window stayed English on a German Mac. The reason: macOS picks a framework's language from the languages the app declares in CFBundleLocalizations. Lowspeak now lists all 35 languages of its interface there, and Sparkle's dialogs and the microphone permission prompt follow the system language.
Bonus: AppKit tests need the main thread
Rust's default test harness doesn't run tests on the main thread, but AppKit menus and status items must be built on the main thread. The integration tests that build the real menu therefore run with harness = false and their own main. The app logic talks to the system only through a Platform trait, so these tests swap in a fake that never touches the real keychain, clipboard or settings.
Try it
Lowspeak is free under MIT or Apache-2.0, with no account and no telemetry. The app is 17 MB, needs about 45 MB of memory in online mode, runs on macOS 14 or later on Apple Silicon and Intel, and speaks 35 languages.
- Download: Lowspeak.dmg, signed and notarized
- Homebrew:
brew install --cask sventmueller/tap/lowspeak - Source and docs: github.com/sventmueller/lowspeak
Have you run into other macOS details like these? I'd like to hear about them in the comments.