Un diario es probablemente el contenido de voz más privado que una persona llega a decir en voz alta. No es «comprar leche», sino aquello que no quieres teclear con el móvil en la mano al final de un día duro. Al diseñar la entrada de voz para Lanternly — un diario con una compañera de IA llamada Luna, actualmente en desarrollo — mantuve un requisito: la nube queda excluida por completo, ni como entrada ni como paso intermedio. El audio y su transcripción no salen del dispositivo bajo ninguna circunstancia.
Y ahí surge la pregunta del motor de reconocimiento. Si no sabes qué ofrece WhisperKit, tengo un repaso aparte sobre qué es WhisperKit y cómo transcribe on-device. Este artículo va de otra cosa: por qué en la producción de Lanternly no acabó él, sino el SFSpeechRecognizer del sistema.
La spec dijo Whisper — el código dijo no#
La primera versión de la build spec de Lanternly nombraba WhisperKit como motor de transcripción: un nombre conocido, calidad de reconocimiento fuerte, un wrapper sobre whisper.cpp con modelos Core ML listos para usar. Pero entre «la spec nombra un motor» y «ese motor está en el proyecto» hay una pregunta que conviene hacerse antes de la primera línea de integración: ¿puede el producto permitirse una dependencia de ese peso?
WhisperKit es un paquete SPM más un modelo que hay que meter en el bundle o descargar en el primer arranque: la cuenta va en cientos de megabytes, y en los modelos precisos, en un gigabyte o más. El Project.swift de Lanternly (el manifiesto de Tuist) no contiene hoy ni una sola dependencia SPM externa — literalmente ninguna. Es una elección consciente: no pagar en peso del binario y en tiempo de arranque en frío por un motor cuya calidad, para esta tarea (dictar notas personales, un solo idioma, grabaciones cortas), es indistinguible de oído de lo que ya viene en el sistema operativo.
En la cabecera de TranscriptionService.swift esto está registrado explícitamente como decisión, no como un TODO olvidado:
// MARK: - TranscriptionService — voz → texto ON-DEVICE (build-spec §3.1)
//
// La spec nombra WhisperKit como motor. Aquí la transcripción está implementada
// sobre Apple Speech con `requiresOnDeviceRecognition = true` — un motor on-device
// honesto que compila sin una dependencia SPM pesada ni un modelo de ~GB. Hay
// exactamente un punto de sustitución: reemplaza el cuerpo de `transcribe`/`isAvailable`
// por WhisperKit y la interfaz se conserva.La frase clave es «exactamente un punto de sustitución». No es renunciar a Whisper para siempre, sino aplazarlo hasta que aparezca una razón real: otro idioma donde el motor del sistema flojee, o un timestamp preciso por palabra. Hasta entonces — para qué.
57 líneas de transcripción#
Toda la implementación es un enum TranscriptionService con métodos estáticos, sin protocolo ni andamiaje de DI: el servicio es exactamente uno, no hay nada que sustituir en runtime, y una abstracción aquí sería sobrecoste sin beneficio. Las 57 líneas completas:
enum TranscriptionService {
private static let locale = Locale(identifier: "ru-RU")
/// Si la transcripción local está disponible en este dispositivo.
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)
}
}
}
/// Transcribe el audio (datos) en el dispositivo. nil — si no está disponible/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 exige un archivo — escribimos en uno temporal.
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) }
}
}
}
}
}Tres detalles aquí no son casuales:
requiresOnDeviceRecognition = true.SFSpeechRecognizeres capaz de enviar el audio a los servidores de Apple si considera que así es más preciso, y sin este flag la decisión de si los datos salen del dispositivo no la toma el desarrollador.isAvailablecomprueba dos cosas, no una. No solorecognizer.isAvailable(el motor está libre y la locale es compatible), sino también por separadosupportsOnDeviceRecognition: en parte de los dispositivos o idiomas el motor local no está disponible en absoluto, solo el de la nube.- La autorización se solicita de forma perezosa, en la primera llamada real a
transcribe, y no al arrancar la app: el usuario ve la alerta del sistema sobre reconocimiento de voz solo cuando realmente ha grabado su voz.
Un archivo, no un stream#
El flujo es deliberadamente de archivo, no de streaming. La grabación de la nota de voz pasa por AVAudioRecorder (Media/AudioRecorder.swift) a m4a/AAC, 44.1kHz, mono, a un archivo temporal — una capa separada de Speech que simplemente escribe el audio a disco y calcula los niveles para el waveform en la UI. AVAudioEngine para reconocimiento en tiempo real no se usa aquí en absoluto.
Al terminar la grabación, el m4a completo se entrega a TranscriptionService.transcribe, que envuelve los datos en su propio luna-stt-<UUID>.m4a temporal — porque SFSpeechURLRecognitionRequest exige precisamente un archivo, no un buffer — y lo borra en defer justo después del resultado. shouldReportPartialResults = false: las hipótesis intermedias de reconocimiento no se solicitan.
Esto es razonable precisamente porque el dictado de un diario no es dictado en tiempo real sobre texto visible, como en las notas o en un chat. El audio se guarda de todos modos como adjunto de la entrada — la transcripción la complementa, no la sustituye. Un dictado en vivo línea a línea añadiría complejidad (buffering, resultados parciales, cancelación al vuelo) sin un beneficio que en este escenario alguien pudiera apreciar: la persona no mira la pantalla mientras habla, simplemente dice su pensamiento y pulsa «stop». Pero el enfoque basado en archivos tiene un punto débil: el motor puede, sencillamente, no estar disponible.
La transcripción es un borrador#
Si el motor no está disponible en el dispositivo — y isAvailable puede devolver false si falta el modelo del idioma o el firmware no lo soporta — la app no finge que todo va bien. VoicePlayerView muestra un aviso honesto (la cadena en ruso dice, aproximadamente, «Las funciones de IA local están limitadas en este dispositivo — la transcripción no está disponible»):
if media.transcript == nil && !TranscriptionService.isAvailable {
// Aviso honesto: el motor no está disponible, pero el audio está grabado y suena.
Label("Функции локального ИИ ограничены на этом устройстве — расшифровка недоступна.",
systemImage: "exclamationmark.circle")
}El audio no desaparece: está grabado y se reproduce; solo se pierde la capa de texto por encima.
La transcripción se invoca desde tres lugares, y en todos es best-effort, asíncrona, después de que la nota ya esté guardada: el editor de entradas (Editor/EntryEditorView.swift), el diario de sueños (Journals/DreamsEntryView.swift) y el chat con Luna (Chat/ChatView.swift, donde la voz se convierte en el texto de un mensaje). El patrón es el mismo: primero se guarda el MediaItem, luego una tarea en segundo plano completa el 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()
// Transcripción on-device (best-effort) — actualizará la nota cuando esté lista.
Task { @MainActor in
if let text = await TranscriptionService.transcribe(data) {
media.transcript = text
try? context.save()
}
}
}Importante: la transcripción es un borrador, no el texto final. El motor del sistema no coloca la puntuación de serie, y en VoicePlayerView el resultado se abre en un TextField editable, vinculado directamente a media.transcript, con una pista al lado: «puedes corregir el texto; la transcripción no siempre es precisa».
Se almacena como MediaItem — un @Model de SwiftData con @Attribute(.externalStorage) var data: Data? (en CloudKit se sincroniza como CKAsset, sin inflar el registro principal). transcript es un campo de texto normal del mismo modelo, así que participa gratis en la búsqueda de texto completo por las entradas y en la exportación: Markdown y el libro en PDF/EPUB ven la transcripción como parte del texto de la nota, sin un pipeline aparte para la voz.
Cuándo sí Whisper#
Esto no es «WhisperKit no hace falta nunca», sino «no hace falta aquí y ahora». Los criterios honestos con los que volvería al comentario de la cabecera del archivo y haría la sustitución:
- un idioma o acento en el que el reconocedor del sistema flojee notablemente (para el P0 de Lanternly, solo
ru-RU; la app es en ruso); - necesitar timestamps por palabra o diarización de varios hablantes, que
SFSpeechRecognizerno ofrece; - que el producto derive hacia escenarios offline donde importa la reproducibilidad del modelo entre versiones de iOS, y no lo que el sistema da aquí y ahora.
Ninguno de estos criterios se ha activado todavía en el dictado de un diario personal.
Justo por eso la interfaz del servicio — isAvailable y transcribe(_:) async -> String? — está diseñada para que sustituir el cuerpo siga siendo la edición de un solo archivo, y no la reescritura de tres puntos de llamada y del modelo de datos.
La pregunta que conviene hacerse antes de la primera dependencia#
Antes de arrastrar a un proyecto un paquete SPM con un modelo de cientos de megabytes o de un gigabyte, vale la pena hacerse la pregunta que es más barata al principio que arreglarla luego en el despliegue: ¿no está ya la calidad necesaria en el sistema operativo, gratis, sin una sola línea de dependencia adicional? SFSpeechRecognizer con requiresOnDeviceRecognition = true no es «aún no hemos llegado a un motor de verdad», sino una solución operativa para una clase concreta de tareas: grabaciones cortas, un solo idioma, el archivo completo en lugar del stream, degradación honesta en lugar de fallo silencioso. Si los requisitos cambian — idioma, precisión, garantías offline — hay exactamente un punto donde cambiarlo, y no una arquitectura que haya que romper. Un principio parecido — la solución del sistema por defecto, la personalizada solo por una razón medible — funcionó también en otra tarea de Lanternly, la traducción pivote para Luna: allí también se comprueba primero qué sabe hacer ya el sistema, y solo después se construye el rodeo a sus límites.



