日记大概是一个人愿意说出口的最私密的语音内容了。不是"买牛奶"这种备忘,而是在艰难一天的尾声,你根本不想握着手机敲出来的那些话。在为 Lanternly(一款带有 AI 伙伴 Luna 的日记应用,目前仍在开发中)设计语音输入时,我坚持一条硬性要求:彻底排除云端,既不作为输入端,也不作为中间环节。音频和转写文本在任何情况下都不离开设备。
于是识别引擎的选择问题就摆在了面前。如果你还不了解 WhisperKit 能提供什么,我另有一篇概览:WhisperKit 是什么,它如何在端上转写。这篇文章讲的是另一件事:为什么最终进入 Lanternly 生产代码的不是它,而是系统自带的 SFSpeechRecognizer。
规格书说用 Whisper——代码说不#
Lanternly 构建规格书的第一版把转写引擎指定为 WhisperKit——名气大,识别质量强,是 whisper.cpp 的封装并附带现成的 Core ML 模型。但在"规格书指定了引擎"和"这个引擎进入项目"之间,有一个值得在写下第一行集成代码之前先问的问题:产品扛得起这种量级的依赖吗?
WhisperKit 是一个 SPM 包,外加一个要么打进 bundle、要么首次启动时下载的模型:动辄几百兆字节,精度高的模型则达到一吉字节以上。而 Lanternly 的 Project.swift(Tuist 清单)今天不包含任何一个外部 SPM 依赖——一个都没有。这是有意为之的选择:对于这个任务(口述个人笔记、单一语言、短录音)来说,一个引擎的质量用耳朵听不出与操作系统里现成的有什么区别,就不该为它付出二进制体积和冷启动时间的代价。
TranscriptionService.swift 的文件头里明确记录了这是一个决定,而不是被遗忘的 TODO:
// MARK: - TranscriptionService — 语音 → 文本 ON-DEVICE(build-spec §3.1)
//
// 规格书指定的引擎是 WhisperKit。这里的转写基于 Apple Speech 实现,
// 并设置 `requiresOnDeviceRecognition = true` —— 这是货真价实的端上引擎,
// 无需沉重的 SPM 依赖和约 1GB 的模型即可构建。替换点只有一个:
// 把 `transcribe`/`isAvailable` 的实现体换成 WhisperKit,接口保持不变。关键短语是"替换点只有一个"。这不是永远拒绝 Whisper,而是把它推迟到出现真实理由的那一刻:某种系统引擎表现较弱的语言,或需要精确到词的时间戳。在那之前——何必呢。
57 行转写代码#
整个实现是一个带静态方法的 enum TranscriptionService,没有协议,也没有 DI 脚手架:服务就只有一个,运行时没有任何东西需要替换,抽象在这里只会是没有收益的开销。完整的 57 行:
enum TranscriptionService {
private static let locale = Locale(identifier: "ru-RU")
/// 本设备上本地转写是否可用。
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)
}
}
}
/// 在设备上转写音频(数据)。不可用/出错时返回 nil。
static func transcribe(_ data: Data) async -> String? {
guard isAvailable, await requestAuthorization() else { return nil }
guard let recognizer = SFSpeechRecognizer(locale: locale) else { return nil }
// Speech 要求文件 —— 写入临时文件。
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) }
}
}
}
}
}这里有三处细节并非偶然:
requiresOnDeviceRecognition = true。SFSpeechRecognizer在认为云端更准时会把音频发送到 Apple 的服务器,没有这个标志,数据是否离开设备就不由开发者说了算。- **
isAvailable检查的是两件事,不是一件。**不仅检查recognizer.isAvailable(引擎空闲且支持该 locale),还单独检查supportsOnDeviceRecognition——在部分设备或语言上,本地引擎根本不存在,只有云端版本。 - 授权采用惰性请求,在第一次真正调用
transcribe时才发起,而不是在应用启动时:用户只有在真的录了音之后,才会看到系统的语音识别授权弹窗。
文件,而不是流#
流程刻意采用文件式而非流式。语音笔记的录制经由 AVAudioRecorder(Media/AudioRecorder.swift)写入 m4a/AAC、44.1kHz、单声道的临时文件——这是与 Speech 分离的一层,只负责把音频写到磁盘,并为 UI 的波形图计算电平。用于实时识别的 AVAudioEngine 在这里完全没有使用。
录制结束后,完整的 m4a 被交给 TranscriptionService.transcribe,它会把数据包进自己的临时文件 luna-stt-<UUID>.m4a——因为 SFSpeechURLRecognitionRequest 要求的就是文件而非缓冲区——并在拿到结果后立即通过 defer 删除。shouldReportPartialResults = false:不请求识别的中间假设。
这样做之所以合理,正是因为日记口述不是像备忘录或聊天那样、叠加在可见文本之上的实时听写。音频无论如何都会作为记录的附件保存下来——转写是对它的补充,而非替代。逐行实时听写会带来复杂度(缓冲、部分结果、中途取消),而在这个场景里没有人能感受到它的好处:人在说话时并不看屏幕,只是把想法说出来,然后按下"停止"。但基于文件的方案有一个薄弱环节:引擎可能干脆不可用。
转写只是草稿#
如果引擎在设备上不可用——当语言模型缺失或固件不支持时,isAvailable 可能返回 false——应用不会假装一切正常。VoicePlayerView 会显示一条坦诚的提示条(俄语字符串大意为"本设备上的本地 AI 功能受限——转写不可用"):
if media.transcript == nil && !TranscriptionService.isAvailable {
// 坦诚的提示条:引擎不可用,但音频已录制且可播放。
Label("Функции локального ИИ ограничены на этом устройстве — расшифровка недоступна.",
systemImage: "exclamationmark.circle")
}音频不会丢失——它已被录制并可以播放,损失的只是覆盖其上的文本层。
转写从三个地方被调用,且都是 best-effort、异步、在笔记已保存之后进行:记录编辑器(Editor/EntryEditorView.swift)、梦境日记(Journals/DreamsEntryView.swift)以及与 Luna 的聊天(Chat/ChatView.swift,语音在这里变成消息文本)。模式完全一致——先保存 MediaItem,再由后台任务补全 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()
// 端上转写(best-effort)—— 完成后更新笔记。
Task { @MainActor in
if let text = await TranscriptionService.transcribe(data) {
media.transcript = text
try? context.save()
}
}
}重要的是:转写是草稿,不是最终文本。系统引擎不会开箱即用地加标点,在 VoicePlayerView 里,结果显示在一个直接绑定到 media.transcript 的可编辑 TextField 中,旁边配有提示——"文本可以修改,转写不一定准确"。
它以 MediaItem 的形式存储——一个带 @Attribute(.externalStorage) var data: Data? 的 SwiftData @Model(在 CloudKit 中作为 CKAsset 同步,而不是撑大主记录)。transcript 是同一模型的普通文本字段,因此它零成本地参与记录的全文搜索和导出:Markdown 以及 PDF/EPUB 成书都把转写视为笔记正文的一部分,无需为语音单独搭一条管线。
什么时候还是要 Whisper#
这不是"永远不需要 WhisperKit",而是"此时此地不需要"。让我回到文件头那条注释并执行替换的坦诚标准是:
- 某种系统识别器明显吃力的语言或口音(Lanternly 的 P0 只有
ru-RU,应用面向俄语用户); - 需要逐词时间戳或多说话人分离,而这些
SFSpeechRecognizer不提供; - 产品走向离线场景,此时模型在不同 iOS 版本间的可复现性比系统当下能给的更重要。
在个人日记听写这件事上,这些标准至今没有一条被触发。
正因如此,服务的接口——isAvailable 和 transcribe(_:) async -> String?——被设计成:替换实现体只是改一个文件,而不是重写三个调用点和数据模型。
引入第一个依赖之前值得问的问题#
在把一个附带几百兆甚至上吉字节模型的 SPM 包拖进项目之前,值得先问一个开头问比事后靠部署来收拾便宜得多的问题:所需的质量是不是已经免费躺在操作系统里,一行额外依赖都不用加?带 requiresOnDeviceRecognition = true 的 SFSpeechRecognizer 不是"还没顾上换正经引擎",而是针对特定任务类别的可用解决方案:短录音、单一语言、整文件而非流式、坦诚降级而非静默失败。如果需求变了——语言、精度、离线保证——需要改动的位置恰好只有一处,而不是一套必须推倒的架构。类似的原则——默认用系统方案,只有存在可衡量的理由时才上自定义——在 Lanternly 的另一个任务中同样奏效:为 Luna 做的中转翻译。那里也是先检查系统已经会什么,然后才为它的限制构建绕行方案。



