Дневник — это, наверное, самые приватные голосовые заметки, какие человек вообще наговаривает вслух. Не «купить молоко», а то, что не хочется печатать с телефона в руке в конце тяжёлого дня. Проектируя голосовой ввод для Lanternly — дневника с AI-компаньоном Луной, который сейчас в разработке, — я держал одно требование: облако исключено полностью, ни на входе, ни промежуточным шагом. Аудио и расшифровка не покидают устройство ни при каких обстоятельствах.
Здесь и встаёт вопрос движка распознавания. Если не в курсе, что вообще предлагает WhisperKit — у меня есть отдельный обзор, что такое WhisperKit и как он транскрибирует on-device. Эта статья — про другое: почему в проде Lanternly оказался не он, а системный SFSpeechRecognizer.
Спека сказала Whisper — код сказал нет#
Первая версия build-спеки Lanternly называла движком транскрипции WhisperKit — на слуху, качество распознавания сильное, обёртка над whisper.cpp с готовыми Core ML моделями. Но между «спека называет движок» и «этот движок в проекте» лежит вопрос, который стоит задать до первой строчки интеграции: тянет ли продукт зависимость такого веса?
WhisperKit — это SPM-пакет плюс модель, которую нужно либо тащить в бандл, либо скачивать при первом запуске: счёт на сотни мегабайт, у точных моделей — на гигабайт и выше. У Lanternly Project.swift (манифест Tuist) сегодня не содержит ни одной внешней SPM-зависимости — вообще ни одной. Это осознанный выбор: не платить весом бинарника и временем холодного старта за движок, качество которого для этой задачи (диктовка личных заметок, один язык, короткие записи) не отличимо на слух от того, что уже лежит в ОС.
В шапке TranscriptionService.swift это прямо зафиксировано как решение, а не забытый TODO:
// MARK: - TranscriptionService — речь → текст ON-DEVICE (build-spec §3.1)
//
// Спека называет движком WhisperKit. Здесь транскрипция реализована на Apple
// Speech с `requiresOnDeviceRecognition = true` — это честный on-device-движок,
// который собирается без тяжёлой SPM-зависимости и модели в ~ГБ. Точка подмены
// одна: замени тело `transcribe`/`isAvailable` на WhisperKit, интерфейс сохранится.Ключевая фраза — «точка подмены одна». Это не отказ от Whisper навсегда, а решение отложить его до момента, когда появится реальная причина: другой язык, где системный движок слабее, или точный timestamp по слову. До тех пор — зачем.
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(движок свободен и локаль поддерживается), но и отдельноsupportsOnDeviceRecognition— на части устройств или языков локальный движок недоступен в принципе, только облачный.- Авторизация запрашивается лениво, при первом реальном вызове
transcribe, а не при старте приложения: системный алерт про распознавание речи пользователь видит только тогда, когда действительно записал голос.
Файл, а не стрим#
Поток осознанно файловый, не потоковый. Запись голосовой заметки идёт через AVAudioRecorder (Media/AudioRecorder.swift) в m4a/AAC, 44.1kHz, моно, во временный файл — отдельный от Speech слой, который просто пишет аудио на диск и считает уровни для waveform в UI. AVAudioEngine для распознавания в реальном времени здесь не используется вообще.
По завершении записи целиком готовый m4a передаётся в TranscriptionService.transcribe, который сам оборачивает данные во временный luna-stt-<UUID>.m4a, потому что SFSpeechURLRecognitionRequest требует именно файл, а не буфер — и удаляет его в defer сразу после результата. shouldReportPartialResults = false: промежуточные гипотезы распознавания не запрашиваются.
Это разумно ровно потому, что диктовка дневника — не диктовка в реальном времени поверх видимого текста, как в заметках или чате. Аудио в любом случае сохраняется как вложение записи — расшифровка дополняет её, а не заменяет. Живая построчная диктовка добавила бы сложность (буферизация, частичные результаты, отмена на лету) без пользы, которую в этом сценарии некому оценить: человек не смотрит на экран, пока говорит, он просто наговаривает мысль и жмёт «стоп». Но у файлового подхода есть слабое место: движок может быть попросту недоступен.
Расшифровка — это черновик#
Если движок недоступен на устройстве — а isAvailable может вернуть false, если модель языка отсутствует или прошивка её не поддерживает — приложение не делает вид, что всё в порядке. VoicePlayerView показывает честную плашку:
if media.transcript == nil && !TranscriptionService.isAvailable {
// Честная плашка: движок недоступен, но аудио записано и играет.
Label("Функции локального ИИ ограничены на этом устройстве — расшифровка недоступна.",
systemImage: "exclamationmark.circle")
}Аудио при этом никуда не девается — оно записано и играет, теряется только текстовый слой поверх него.
Расшифровка вызывается из трёх мест, и везде — best-effort, асинхронно, после того как заметка уже сохранена: редактор записи (Editor/EntryEditorView.swift), дневник снов (Journals/DreamsEntryView.swift) и чат с Лунoй (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()
// Расшифровка on-device (best-effort) — обновит заметку, когда будет готова.
Task { @MainActor in
if let text = await TranscriptionService.transcribe(data) {
media.transcript = text
try? context.save()
}
}
}Важно: расшифровка — черновик, а не финальный текст. Системный движок не расставляет пунктуацию из коробки, и в VoicePlayerView результат открывается в редактируемом TextField, привязанном напрямую к media.transcript, с подсказкой рядом — «текст можно поправить, расшифровка не всегда точна».
Хранится это как MediaItem — SwiftData @Model с @Attribute(.externalStorage) var data: Data? (в CloudKit синхронизируется как CKAsset, а не раздувает основную запись). transcript — обычное текстовое поле той же модели, поэтому оно бесплатно участвует в полнотекстовом поиске по записям и в экспорте: Markdown и книга в PDF/EPUB видят расшифровку как часть текста заметки, без отдельного пайплайна для голоса.
Когда всё-таки Whisper#
Это не «WhisperKit не нужен никогда», а «не нужен здесь и сейчас». Честные критерии, при которых я вернулся бы к комментарию в шапке файла и сделал подмену:
- язык или акцент, на котором системный распознаватель заметно проседает (для P0 Lanternly — только
ru-RU, приложение русскоязычное); - нужен пословный timestamp или диаризация нескольких говорящих, чего
SFSpeechRecognizerне даёт; - продукт уходит в оффлайн-сценарии, где важна воспроизводимость модели между версиями iOS, а не то, что даёт система здесь и сейчас.
Ни один из этих критериев в диктовке личного дневника пока не сработал.
Ровно поэтому интерфейс сервиса — isAvailable и transcribe(_:) async -> String? — спроектирован так, чтобы подмена тела осталась правкой одного файла, а не переписыванием трёх точек вызова и модели данных.
Вопрос, который стоит задать до первой зависимости#
Прежде чем тянуть в проект SPM-пакет с моделью на сотни мегабайт или гигабайт, стоит задать вопрос, который дешевле задать в начале, чем закрывать деплоем позже: не лежит ли нужное качество уже в ОС бесплатно, без единой строчки дополнительной зависимости? SFSpeechRecognizer с requiresOnDeviceRecognition = true — не «пока не дошли руки до нормального движка», а рабочее решение для конкретного класса задач: короткие записи, один язык, файл целиком вместо потока, честная деградация вместо тихого сбоя. Если требования изменятся — язык, точность, оффлайн-гарантии, — есть ровно одна точка, где это менять, а не архитектура, которую придётся ломать. Похожий принцип — системное решение по умолчанию, кастомное только под измеримую причину — работал и в другой задаче Lanternly, pivot-перевод для Луны: там тоже сначала проверяется, что уже умеет система, и только потом строится обход её ограничений.



