blog · · Mohak Bajaj
Calling Apple's SpeechAnalyzer from an Electron app
Jot is a Mac app for capture and dictation. Hold fn, talk, and the words are typed into the app in front of you. On macOS 26, the transcription runs on Apple's new SpeechAnalyzer, on the Mac, with no network call. Jot is an Electron app, and Electron cannot call a Swift API. Here is the bridge. It is one Swift file of 178 lines.
A helper binary, not a native addon
A Node native addon would need Objective-C++ glue, a build per Electron version, and Swift concurrency inside Node's process. A separate binary needs none of that. If the helper crashes, the app survives. It also keeps the Swift code easy to test from a terminal.
The helper is called jot-speech and has three modes:
jot-speech --check [--locale TAG] availability and locales, one JSON line jot-speech --install TAG download the locale's model, progress lines, then done jot-speech --serve one JSON request per stdin line, one reply per stdout line
Audio never leaves the Mac. Only --install talks to Apple's servers, and only to download the speech model for a language.
The wire: JSON lines
--serve reads one JSON object per line on stdin and writes one reply per line on stdout. Each request has an id, so the Node side can match replies to promises. Nothing else is needed: no socket, no XPC, no framing beyond a newline.
→ {"id":1,"locale":"en-US"} load the model only
← {"id":1,"text":""}
→ {"id":2,"locale":"en-US","path":"/tmp/a.wav"}
← {"id":2,"segments":["Hello there.","Remind me to call Sam."]}On the Node side, the reader is a map from id to a pending promise, with a timeout and an AbortSignal for each request. If the helper exits, every pending request fails at once, and the next request starts a new helper.
Warm before you finish talking
Loading a speech model is the slow part. So the moment dictation starts, Jot sends a request with a locale and no path. The helper loads the model while you are still talking. When you let go, the audio is written to a temporary WAV file and sent as a second request, to a model that is already in memory.
modelRetention: .processLifetime keeps the model loaded between requests. The helper stays alive for 10 minutes after the last request, then Jot stops it to free the memory.
The transcription
let analyzer = SpeechAnalyzer(
modules: [module],
options: .init(priority: .userInitiated, modelRetention: .processLifetime)
)
let file = try AVAudioFile(forReading: URL(fileURLWithPath: path))
let collect = Task {
var segments: [String] = []
for try await result in module.results {
segments.append(String(result.text.characters))
}
return segments
}
if let end = try await analyzer.analyzeSequence(from: file) {
try await analyzer.finalizeAndFinish(through: end)
} else {
await analyzer.cancelAndFinishNow()
}
return try await collect.valueSpeechTranscriber uses the .transcription preset. Each final result is one segment. The helper sends the segments as a list, and the app joins them. If it joined them as plain strings, "hello." and "Hey" would run together. The join also knows that Chinese and Japanese text has no spaces between segments.
Assets belong to each app
This surprised me. The speech model for a language can already be on disk, because you use Dictation or another app installed it. AssetInventory still reports that it is not installed for your app. Your app has to reserve the locale first. So before it offers a download, the helper checks SpeechTranscriber.installedLocales. If the locale is there, the helper calls AssetInventory.reserve(locale:) and checks the status again. In many cases, that saves a download of a few hundred MB.
When a download is needed, --install prints its progress every 250 ms as {"progress":0.42}, and the app shows how far the download is.
Building it
for arch in arm64 x86_64; do
xcrun swiftc -parse-as-library -O -target "$arch-apple-macos26.0" \
-o "$tmp/jot-speech-$arch" helpers/speech.swift
done
lipo -create -output resources/bin/jot-speech \
"$tmp/jot-speech-arm64" "$tmp/jot-speech-x86_64"One universal binary goes into the app bundle and is signed with the app. The build targets macOS 26. On older systems, Jot never starts the helper. It uses NVIDIA Parakeet, a local model, in its own process.
What I would tell you before you try this
- Use
-parse-as-librarywith an@mainstruct andstatic func main() async. Then top-level async code just works. - Read stdin with
FileHandle.standardInput.bytes.lines. When the app quits, stdin closes, the loop ends, and the helper exits by itself. - Write each reply in one call, ending with a newline, so two replies never mix.
- Check the locale with
supportedLocale(equivalentTo:). Then "en" finds "en-US".
The pattern works for any new Apple API that only exists in Swift. Jot uses a second small Swift helper the same way, for text cleanup with Apple Intelligence.
Jot is a Mac app for capture and dictation. It's free for 3 days, then $9 once at the launch price. sayjot.xyz