日記とは、人が声に出して語る中でおそらく最もプライベートな音声コンテンツだ。「牛乳を買う」ではなく、つらい一日の終わりにスマホを手にタイプしたくないような内容である。AI コンパニオン「Luna」を備えた開発中の日記アプリ Lanternly の音声入力を設計するにあたり、私はひとつの要件を貫いた:クラウドは完全に除外する。入力としても、中間ステップとしても使わない。音声とその文字起こしは、いかなる状況でもデバイスの外に出ない。
そこで浮上するのが、認識エンジンの選択という問題だ。そもそも WhisperKit が何を提供するのか知らない場合は、WhisperKit とは何か、どうやってオンデバイスで文字起こしするのかという別の概説記事がある。この記事のテーマは別で、なぜ Lanternly のプロダクションに入ったのがそれではなく、システム標準の SFSpeechRecognizer だったのか、という話だ。
仕様書は Whisper と言い、コードはノーと言った#
Lanternly のビルド仕様書の初版は、文字起こしエンジンとして WhisperKit を挙げていた。知名度が高く、認識品質も強力で、whisper.cpp のラッパーに Core ML モデルが同梱されている。しかし「仕様書がエンジンを挙げている」ことと「そのエンジンがプロジェクトに入る」ことの間には、統合の最初の一行を書く前に問うべき質問がある:この重さの依存をプロダクトは背負えるのか?
WhisperKit は SPM パッケージに加えてモデルが必要で、バンドルに同梱するか初回起動時にダウンロードすることになる。その量は数百メガバイト、高精度モデルなら 1 ギガバイト以上に及ぶ。Lanternly の Project.swift(Tuist マニフェスト)には、今日の時点で外部 SPM 依存がひとつもない — 文字どおりゼロだ。これは意図的な選択である。このタスク(個人メモの口述、単一言語、短い録音)において、OS に既に入っているものと耳で聞き分けられない品質のエンジンのために、バイナリサイズとコールドスタート時間で対価を払わない、ということだ。
TranscriptionService.swift のヘッダには、これが忘れられた TODO ではなく決定であることが明記されている:
// MARK: - TranscriptionService — 音声 → テキスト ON-DEVICE(build-spec §3.1)
//
// 仕様書はエンジンとして WhisperKit を挙げている。ここでは文字起こしを Apple
// Speech の `requiresOnDeviceRecognition = true` で実装した — 重い SPM 依存や
// 〜GB のモデルなしにビルドできる、正真正銘のオンデバイスエンジンである。
// 差し替えポイントはひとつだけ:`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) }
}
}
}
}
}ここには偶然ではない 3 つのディテールがある:
requiresOnDeviceRecognition = true。SFSpeechRecognizerは、その方が精度が高いと判断すれば音声を Apple のサーバーに送信できる。このフラグがなければ、データがデバイスを離れるかどうかの決定権は開発者の手にない。isAvailableはひとつではなくふたつのことを確認する。recognizer.isAvailable(エンジンが空いていてロケールがサポートされている)だけでなく、別途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")
}音声はどこにも消えない — 録音されており、再生もできる。失われるのは、その上のテキストレイヤだけだ。
文字起こしは 3 か所から呼び出され、いずれもベストエフォートで非同期、メモが既に保存された後に実行される:記録エディタ(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()
// オンデバイス文字起こし(ベストエフォート)— 準備でき次第メモを更新する。
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? — は、本体の差し替えが 3 つの呼び出し箇所とデータモデルの書き直しではなく、1 ファイルの修正で済むように設計されている。
最初の依存関係を入れる前に問うべきこと#
数百メガバイト、あるいはギガバイト級のモデル付き SPM パッケージをプロジェクトに引き込む前に、後でデプロイで尻拭いするより最初に問うほうが安くつく質問をすべきだ:必要な品質は、追加依存の一行もなしに、既に OS の中に無料で転がっていないか?requiresOnDeviceRecognition = true を付けた SFSpeechRecognizer は「まともなエンジンにまだ手が回っていない」状態ではなく、特定のタスククラス — 短い録音、単一言語、ストリームではなくファイル丸ごと、静かな失敗ではなく正直なデグラデーション — に対する実用解である。要件が変われば — 言語、精度、オフライン保証 — 変更すべき場所はちょうど 1 か所であり、壊さなければならないアーキテクチャではない。同じ原則 — デフォルトはシステム標準、カスタムは測定可能な理由があるときだけ — は Lanternly の別のタスク、Luna のためのピボット翻訳でも機能した。そこでもまず、システムが既に何をできるかを確認し、その後で初めてその制約の回避策を構築している。



