A journal is probably the most private voice content a person ever says out loud. Not "buy milk" — the things you don't want to type on a phone at the end of a hard day. While designing voice input for Lanternly — a journaling app with an AI companion named Luna, currently in development — I held one requirement: the cloud is ruled out entirely, neither as an input nor as an intermediate step. Audio and its transcript never leave the device under any circumstances.
That's where the question of the recognition engine comes in. If you're not familiar with what WhisperKit even offers, I have a separate overview of what WhisperKit is and how it transcribes on-device. This article is about something else: why the engine that ended up in Lanternly's production code isn't WhisperKit but the system SFSpeechRecognizer.
The spec said Whisper — the code said no#
The first version of the Lanternly build spec named WhisperKit as the transcription engine — a well-known name, strong recognition quality, a wrapper around whisper.cpp with ready-made Core ML models. But between "the spec names an engine" and "that engine is in the project" lies a question worth asking before the first line of integration: can the product afford a dependency of that weight?
WhisperKit is an SPM package plus a model that you either bundle or download on first launch: hundreds of megabytes, and for the accurate models a gigabyte and up. Lanternly's Project.swift (the Tuist manifest) contains not a single external SPM dependency today — literally none. That's a deliberate choice: don't pay in binary size and cold-start time for an engine whose quality, for this task (dictating personal notes, one language, short recordings), is indistinguishable by ear from what already ships in the OS.
The header of TranscriptionService.swift records this explicitly as a decision, not a forgotten TODO:
// MARK: - TranscriptionService — speech → text ON-DEVICE (build-spec §3.1)
//
// The spec names WhisperKit as the engine. Here transcription is built on Apple
// Speech with `requiresOnDeviceRecognition = true` — an honest on-device engine
// that builds without a heavy SPM dependency and a ~GB model. There is exactly
// one swap point: replace the body of `transcribe`/`isAvailable` with WhisperKit
// and the interface stays intact.The key phrase is "exactly one swap point". This isn't rejecting Whisper forever — it's deferring it until a real reason appears: another language where the system engine is weaker, or precise per-word timestamps. Until then — why.
57 lines of transcription#
The entire implementation is an enum TranscriptionService with static methods, no protocol and no DI plumbing: there is exactly one service, there's nothing to swap at runtime, and an abstraction here would be overhead with no payoff. All 57 lines:
enum TranscriptionService {
private static let locale = Locale(identifier: "ru-RU")
/// Whether local transcription is available on this device.
static var isAvailable: Bool {
guard let recognizer = SFSpeechRecognizer(locale: locale) else { return false }
return recognizer.isAvailable && recognizer.supportsOnDeviceRecognition
}
static func requestAuthorization() async -> Bool {
await withCheckedContinuation { cont in
SFSpeechRecognizer.requestAuthorization { status in
cont.resume(returning: status == .authorized)
}
}
}
/// Transcribes audio (data) on-device. nil — if unavailable/error.
static func transcribe(_ data: Data) async -> String? {
guard isAvailable, await requestAuthorization() else { return nil }
guard let recognizer = SFSpeechRecognizer(locale: locale) else { return nil }
// Speech requires a file — write to a temporary one.
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("luna-stt-\(UUID().uuidString).m4a")
guard (try? data.write(to: url)) != nil else { return nil }
defer { try? FileManager.default.removeItem(at: url) }
let request = SFSpeechURLRecognitionRequest(url: url)
request.requiresOnDeviceRecognition = true
request.shouldReportPartialResults = false
return await withCheckedContinuation { cont in
var resumed = false
recognizer.recognitionTask(with: request) { result, error in
if let result, result.isFinal {
if !resumed { resumed = true; cont.resume(returning: result.bestTranscription.formattedString) }
} else if error != nil {
if !resumed { resumed = true; cont.resume(returning: nil) }
}
}
}
}
}Three details here are not accidental:
requiresOnDeviceRecognition = true.SFSpeechRecognizeris capable of sending audio to Apple's servers if it decides that's more accurate, and without this flag the decision about whether data leaves the device is not made by the developer.isAvailablechecks two things, not one. Not onlyrecognizer.isAvailable(the engine is free and the locale is supported) but also, separately,supportsOnDeviceRecognition— on some devices or for some languages the local engine simply doesn't exist, only the cloud one.- Authorization is requested lazily, on the first real call to
transcribe, not at app launch: the user sees the system speech-recognition alert only once they've actually recorded their voice.
A file, not a stream#
The pipeline is deliberately file-based, not streaming. Voice-note recording goes through AVAudioRecorder (Media/AudioRecorder.swift) into m4a/AAC, 44.1kHz, mono, to a temporary file — a layer separate from Speech that simply writes audio to disk and computes levels for the waveform in the UI. AVAudioEngine for real-time recognition is not used here at all.
When recording finishes, the complete m4a is handed to TranscriptionService.transcribe, which wraps the data in its own temporary luna-stt-<UUID>.m4a — because SFSpeechURLRecognitionRequest requires a file, not a buffer — and deletes it in defer right after the result. shouldReportPartialResults = false: intermediate recognition hypotheses are never requested.
This is reasonable precisely because journal dictation is not real-time dictation over visible text, like in notes or a chat. The audio is stored as an attachment to the entry either way — the transcript complements it rather than replacing it. Live line-by-line dictation would add complexity (buffering, partial results, mid-flight cancellation) with no benefit anyone in this scenario would notice: the person isn't looking at the screen while speaking — they just voice a thought and tap "stop". But the file-based approach has a weak spot: the engine may simply be unavailable.
The transcript is a draft#
If the engine is unavailable on the device — and isAvailable may return false when the language model is missing or the firmware doesn't support it — the app doesn't pretend everything is fine. VoicePlayerView shows an honest banner (the Russian string reads roughly "Local AI features are limited on this device — transcription is unavailable"):
if media.transcript == nil && !TranscriptionService.isAvailable {
// Honest banner: the engine is unavailable, but the audio is recorded and plays.
Label("Функции локального ИИ ограничены на этом устройстве — расшифровка недоступна.",
systemImage: "exclamationmark.circle")
}The audio doesn't go anywhere — it's recorded and it plays; only the text layer on top of it is lost.
Transcription is invoked from three places, and in all of them it's best-effort, asynchronous, and happens after the note is already saved: the entry editor (Editor/EntryEditorView.swift), the dream journal (Journals/DreamsEntryView.swift), and the chat with Luna (Chat/ChatView.swift, where voice becomes the text of a message). The pattern is the same everywhere — the MediaItem is saved first, then a background task fills in the transcript:
private func addVoice(data: Data, duration: TimeInterval) {
let target = ensureEntry()
let media = MediaItem(kind: .voice, data: data, duration: duration)
context.insert(media)
media.entry = target
try? context.save()
reportQuick()
// On-device transcription (best-effort) — updates the note when ready.
Task { @MainActor in
if let text = await TranscriptionService.transcribe(data) {
media.transcript = text
try? context.save()
}
}
}Importantly: the transcript is a draft, not final text. The system engine doesn't punctuate out of the box, and in VoicePlayerView the result opens in an editable TextField bound directly to media.transcript, with a hint next to it — "you can edit the text; transcription isn't always accurate".
It's stored as a MediaItem — a SwiftData @Model with @Attribute(.externalStorage) var data: Data? (synced to CloudKit as a CKAsset instead of bloating the main record). transcript is an ordinary text field of the same model, so it participates for free in full-text search across entries and in export: Markdown and the PDF/EPUB book see the transcript as part of the note's text, with no separate pipeline for voice.
When Whisper after all#
This isn't "WhisperKit is never needed" — it's "not needed here and now". The honest criteria under which I'd go back to the comment at the top of the file and do the swap:
- a language or accent where the system recognizer visibly struggles (for Lanternly's P0 it's only
ru-RU; the app is Russian-language); - a need for per-word timestamps or multi-speaker diarization, which
SFSpeechRecognizerdoesn't provide; - the product moving into offline scenarios where model reproducibility across iOS versions matters more than whatever the system offers at the moment.
None of these criteria has fired for personal journal dictation so far.
That's exactly why the service's interface — isAvailable and transcribe(_:) async -> String? — is designed so that swapping the body remains a one-file edit, not a rewrite of three call sites and the data model.
The Question Worth Asking Before Your First Dependency#
Before pulling an SPM package with a model of hundreds of megabytes or a gigabyte into a project, it's worth asking the question that is cheaper to ask up front than to fix in deployment later: doesn't the OS already ship the quality you need for free, without a single line of extra dependency? SFSpeechRecognizer with requiresOnDeviceRecognition = true isn't "we haven't gotten around to a proper engine yet" — it's a working solution for a specific class of tasks: short recordings, one language, a whole file instead of a stream, honest degradation instead of silent failure. If the requirements change — language, accuracy, offline guarantees — there is exactly one place to change it, not an architecture that has to be broken. The same principle — system default first, custom only for a measurable reason — worked in another Lanternly task, the pivot translation for Luna: there too, the code first checks what the system can already do, and only then builds a workaround for its limits.



