Asistentes de Voz con IA On-Device: Implementación Nativa en iOS y Android 2026

Guía técnica completa para desarrollar asistentes de voz con IA que funcionan completamente offline usando Speech Framework de iOS y ML Kit de Android. Código real, arquitectura y optimizaciones.

iOS Android IA Speech Recognition On-Device CoreML ML Kit

Asistentes de Voz con IA On-Device: Implementación Nativa en iOS y Android 2026

Después de tres años implementando asistentes de voz en aplicaciones móviles que manejan desde 10K hasta 300K usuarios activos, he visto la evolución brutal del reconocimiento de voz on-device. En 2026, ya no necesitas enviar audio a servidores para tener un asistente inteligente.

El State of Mobile 2026 confirma lo que veo en producción: los asistentes de IA superan el crecimiento de gaming y redes sociales. La razón es simple: privacidad + latencia + costo. Tu voz no sale del dispositivo, respuesta en <200ms, y cero llamadas a APIs.

¿Por Qué On-Device en 2026?

La Realidad del Mercado

En Leonard AI, nuestra app para centros de estética procesaba consultas de voz de 300+ centros diariamente. Migrar de Google Speech-to-Text (server) a on-device nos dio:

  • Latencia: 2.3s → 180ms promedio
  • Costo: €1,200/mes → €0 en transcripción
  • Privacidad: GDPR compliance sin disclaimers
  • Disponibilidad: Funciona sin internet

Apple Intelligence + Google’s WAXAL

Apple selló acuerdo con Google para usar Gemini AI como cerebro detrás del nuevo Siri, pero el procesamiento de voz sigue siendo on-device. Google lanzó WAXAL, su dataset multilingual para entrenar modelos ASR y TTS que corren localmente.

El mensaje es claro: el futuro es híbrido. Voz on-device, razonamiento opcional en cloud.

Arquitectura de un Asistente On-Device

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Audio Input   │ -> │  Speech-to-Text │ -> │   Intent Parse  │
│   (Microphone)  │    │   (On-Device)   │    │   (Local LLM)   │
└─────────────────┘    └─────────────────┘    └─────────────────┘

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│  Audio Output   │ <- │  Text-to-Speech │ <- │  Response Gen   │
│   (Speaker)     │    │   (On-Device)   │    │  (CoreML/ONNX)  │
└─────────────────┘    └─────────────────┘    └─────────────────┘

Componentes Críticos

  1. Speech Recognition: iOS Speech Framework, Android ML Kit
  2. Intent Processing: CoreML/TensorFlow Lite models
  3. Response Generation: Local LLM (Llama 4 quantized, ONNX)
  4. Text-to-Speech: AVSpeechSynthesizer, Android TTS Engine

Implementación iOS: Speech Framework + CoreML

Setup del Speech Recognition

import Speech
import CoreML

class VoiceAssistant: NSObject, ObservableObject {
    private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))!
    private let audioEngine = AVAudioEngine()
    private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
    private var recognitionTask: SFSpeechRecognitionTask?
    
    @Published var transcription = ""
    @Published var isListening = false
    
    override init() {
        super.init()
        setupAudioSession()
        requestPermissions()
    }
    
    private func setupAudioSession() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playAndRecord, mode: .measurement, options: .duckOthers)
            try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
        } catch {
            print("Audio session setup failed: \(error)")
        }
    }
    
    private func requestPermissions() {
        SFSpeechRecognizer.requestAuthorization { authStatus in
            switch authStatus {
            case .authorized:
                print("Speech recognition autorized")
            case .denied, .restricted, .notDetermined:
                print("Speech recognition not available")
            @unknown default:
                break
            }
        }
    }
}

Reconocimiento en Tiempo Real

extension VoiceAssistant {
    func startListening() throws {
        // Cancelar tarea anterior
        recognitionTask?.cancel()
        recognitionTask = nil
        
        // Configurar request
        recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
        guard let recognitionRequest = recognitionRequest else {
            throw VoiceError.recognitionUnavailable
        }
        
        // On-device processing (iOS 13+)
        if #available(iOS 13.0, *) {
            recognitionRequest.requiresOnDeviceRecognition = true
        }
        
        recognitionRequest.shouldReportPartialResults = true
        
        // Setup del audio input node
        let inputNode = audioEngine.inputNode
        let recordingFormat = inputNode.outputFormat(forBus: 0)
        
        inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
            recognitionRequest.append(buffer)
        }
        
        // Iniciar motor de audio
        audioEngine.prepare()
        try audioEngine.start()
        
        // Crear tarea de reconocimiento
        recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { result, error in
            if let result = result {
                DispatchQueue.main.async {
                    self.transcription = result.bestTranscription.formattedString
                    
                    // Procesar comando cuando la transcripción esté estable
                    if result.isFinal {
                        self.processCommand(self.transcription)
                    }
                }
            }
            
            if error != nil {
                self.stopListening()
            }
        }
        
        isListening = true
    }
    
    func stopListening() {
        audioEngine.stop()
        audioEngine.inputNode.removeTap(onBus: 0)
        recognitionRequest?.endAudio()
        recognitionTask?.cancel()
        
        recognitionRequest = nil
        recognitionTask = nil
        isListening = false
    }
}

Procesamiento de Intent con CoreML

class IntentProcessor {
    private var model: VoiceIntentClassifier?
    
    init() {
        loadModel()
    }
    
    private func loadModel() {
        do {
            // Modelo CoreML entrenado para clasificar intents
            let config = MLModelConfiguration()
            config.allowLowPrecisionAccumulationOnGPU = true
            model = try VoiceIntentClassifier(configuration: config)
        } catch {
            print("Failed to load CoreML model: \(error)")
        }
    }
    
    func processIntent(_ text: String) -> VoiceCommand? {
        guard let model = model else { return nil }
        
        do {
            let prediction = try model.prediction(text: text)
            let intent = prediction.intent
            let confidence = prediction.intentProbability[intent] ?? 0.0
            
            // Solo procesar si confidence > 80%
            guard confidence > 0.8 else { return nil }
            
            switch intent {
            case "search_query":
                return .search(query: extractQuery(from: text))
            case "timer_set":
                return .setTimer(duration: extractDuration(from: text))
            case "weather_query":
                return .weather(location: extractLocation(from: text))
            default:
                return nil
            }
        } catch {
            print("Intent prediction failed: \(error)")
            return nil
        }
    }
}

enum VoiceCommand {
    case search(query: String)
    case setTimer(duration: TimeInterval)
    case weather(location: String)
}

Response Generation con Local LLM

class LocalResponseGenerator {
    private var llmModel: MLModel?
    
    init() {
        loadLLMModel()
    }
    
    private func loadLLMModel() {
        do {
            // Modelo Llama 4 quantizado (CoreML)
            let modelURL = Bundle.main.url(forResource: "llama4_quantized", withExtension: "mlmodelc")!
            llmModel = try MLModel(contentsOf: modelURL)
        } catch {
            print("Failed to load LLM model: \(error)")
        }
    }
    
    func generateResponse(for command: VoiceCommand) async -> String {
        switch command {
        case .search(let query):
            return await generateSearchResponse(query)
        case .setTimer(let duration):
            return formatTimerResponse(duration)
        case .weather(let location):
            return await generateWeatherResponse(location)
        }
    }
    
    private func generateSearchResponse(_ query: String) async -> String {
        guard let model = llmModel else { 
            return "Lo siento, no puedo procesar esa búsqueda ahora mismo."
        }
        
        do {
            let prompt = "Usuario: \(query)\nAsistente:"
            let input = try MLDictionaryFeatureProvider(dictionary: ["prompt": prompt])
            let prediction = try model.prediction(from: input)
            
            if let response = prediction.featureValue(for: "response")?.stringValue {
                return response
            }
        } catch {
            print("LLM generation failed: \(error)")
        }
        
        return "No he podido generar una respuesta para: \(query)"
    }
}

Implementación Android: ML Kit + TensorFlow Lite

Setup del Speech Recognizer

import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.speech.Speech
import com.google.mlkit.speech.SpeechRecognizer
import com.google.mlkit.speech.SpeechRecognitionCallback
import com.google.mlkit.speech.SpeechRecognitionResult

class VoiceAssistant(private val context: Context) {
    private var speechRecognizer: SpeechRecognizer? = null
    private var isListening = false
    
    companion object {
        private const val REQUEST_CODE_SPEECH = 1001
    }
    
    fun initializeSpeechRecognizer() {
        speechRecognizer = Speech.getClient(
            Speech.SpeechRecognizerOptions.builder()
                .setLanguageTag("es-ES")
                .setOnDeviceRecognitionEnabled(true)  // Clave para on-device
                .build()
        )
    }
    
    fun startListening(callback: (String) -> Unit) {
        if (isListening) return
        
        speechRecognizer?.let { recognizer ->
            val recognitionCallback = object : SpeechRecognitionCallback {
                override fun onResults(results: List<SpeechRecognitionResult>) {
                    val transcript = results.firstOrNull()?.alternatives?.firstOrNull()?.text ?: ""
                    if (transcript.isNotEmpty()) {
                        callback(transcript)
                        processCommand(transcript)
                    }
                }
                
                override fun onPartialResults(results: List<SpeechRecognitionResult>) {
                    // Actualizar UI con resultados parciales
                    val partialTranscript = results.firstOrNull()?.alternatives?.firstOrNull()?.text ?: ""
                    callback(partialTranscript)
                }
                
                override fun onError(error: Int) {
                    isListening = false
                    Log.e("VoiceAssistant", "Speech recognition error: $error")
                }
                
                override fun onEndOfSpeech() {
                    isListening = false
                }
            }
            
            recognizer.setRecognitionCallback(recognitionCallback)
            recognizer.startListening()
            isListening = true
        }
    }
}

Intent Processing con TensorFlow Lite

import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.support.tensorbuffer.TensorBuffer
import org.tensorflow.lite.support.common.FileUtil

class IntentProcessor(private val context: Context) {
    private var interpreter: Interpreter? = null
    private val labels = listOf("search_query", "timer_set", "weather_query", "unknown")
    
    init {
        loadModel()
    }
    
    private fun loadModel() {
        try {
            val modelBuffer = FileUtil.loadMappedFile(context, "voice_intent_model.tflite")
            interpreter = Interpreter(modelBuffer)
        } catch (e: Exception) {
            Log.e("IntentProcessor", "Failed to load model", e)
        }
    }
    
    fun classifyIntent(text: String): VoiceCommand? {
        interpreter?.let { interp ->
            try {
                // Tokenizar y preparar input
                val inputArray = tokenizeText(text)
                val inputBuffer = TensorBuffer.createFixedSize(intArrayOf(1, 50), DataType.FLOAT32)
                inputBuffer.loadArray(inputArray)
                
                // Ejecutar inferencia
                val outputBuffer = TensorBuffer.createFixedSize(intArrayOf(1, 4), DataType.FLOAT32)
                interp.run(inputBuffer.buffer, outputBuffer.buffer)
                
                // Procesar output
                val probabilities = outputBuffer.floatArray
                val maxIndex = probabilities.indices.maxByOrNull { probabilities[it] } ?: -1
                val confidence = probabilities[maxIndex]
                
                // Solo procesar si confidence > 0.8
                if (confidence > 0.8 && maxIndex < labels.size) {
                    return when (labels[maxIndex]) {
                        "search_query" -> VoiceCommand.Search(extractQuery(text))
                        "timer_set" -> VoiceCommand.SetTimer(extractDuration(text))
                        "weather_query" -> VoiceCommand.Weather(extractLocation(text))
                        else -> null
                    }
                }
            } catch (e: Exception) {
                Log.e("IntentProcessor", "Classification failed", e)
            }
        }
        return null
    }
    
    private fun tokenizeText(text: String): FloatArray {
        // Implementación de tokenización simple
        // En producción usar SentencePiece o WordPiece
        val words = text.toLowerCase().split(" ")
        val tokens = FloatArray(50) { 0f }  // Fixed size input
        
        words.forEachIndexed { index, word ->
            if (index < 50) {
                tokens[index] = word.hashCode().toFloat() / Int.MAX_VALUE
            }
        }
        return tokens
    }
}

sealed class VoiceCommand {
    data class Search(val query: String) : VoiceCommand()
    data class SetTimer(val duration: Long) : VoiceCommand()
    data class Weather(val location: String) : VoiceCommand()
}

Response Generation con ONNX Runtime

import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession

class LocalResponseGenerator(private val context: Context) {
    private var ortSession: OrtSession? = null
    private var environment: OrtEnvironment? = null
    
    init {
        loadONNXModel()
    }
    
    private fun loadONNXModel() {
        try {
            environment = OrtEnvironment.getEnvironment()
            val modelBytes = context.assets.open("llama4_quantized.onnx").readBytes()
            ortSession = environment?.createSession(modelBytes)
        } catch (e: Exception) {
            Log.e("ResponseGenerator", "Failed to load ONNX model", e)
        }
    }
    
    suspend fun generateResponse(command: VoiceCommand): String = withContext(Dispatchers.Default) {
        when (command) {
            is VoiceCommand.Search -> generateSearchResponse(command.query)
            is VoiceCommand.SetTimer -> formatTimerResponse(command.duration)
            is VoiceCommand.Weather -> generateWeatherResponse(command.location)
        }
    }
    
    private fun generateSearchResponse(query: String): String {
        ortSession?.let { session ->
            environment?.let { env ->
                try {
                    val prompt = "Usuario: $query\nAsistente:"
                    val inputTensor = OnnxTensor.createTensor(env, arrayOf(prompt))
                    
                    val inputs = mapOf("input_text" to inputTensor)
                    val results = session.run(inputs)
                    
                    val outputTensor = results[0].value as Array<*>
                    val response = outputTensor[0] as String
                    
                    inputTensor.close()
                    results.close()
                    
                    return response
                } catch (e: Exception) {
                    Log.e("ResponseGenerator", "Generation failed", e)
                }
            }
        }
        
        return "No he podido generar una respuesta para: $query"
    }
    
    private fun formatTimerResponse(duration: Long): String {
        val minutes = duration / 60
        val seconds = duration % 60
        return "Timer configurado para ${minutes} minutos y ${seconds} segundos."
    }
}

Optimizaciones de Rendimiento

iOS: Gestión de Memoria y CPU

class PerformanceOptimizer {
    static func optimizeForVoiceProcessing() {
        // 1. Configurar QoS apropiado
        DispatchQueue.global(qos: .userInteractive).async {
            // Speech processing en high priority
        }
        
        // 2. Minimizar allocaciones durante recording
        let audioSession = AVAudioSession.sharedInstance()
        try? audioSession.setPreferredIOBufferDuration(0.005)  // 5ms buffer
        
        // 3. Usar Metal Performance Shaders para CoreML cuando sea posible
        let config = MLModelConfiguration()
        config.computeUnits = .cpuAndGPU
        config.allowLowPrecisionAccumulationOnGPU = true
    }
    
    static func manageMemoryPressure() {
        // Descargar modelos no usados
        NotificationCenter.default.addObserver(
            forName: UIApplication.didReceiveMemoryWarningNotification,
            object: nil,
            queue: .main
        ) { _ in
            // Unload non-critical models
            ModelManager.shared.unloadSecondaryModels()
        }
    }
}

Android: Optimización de TensorFlow Lite

class AndroidPerformanceOptimizer {
    
    fun optimizeTFLiteInterpreter(): Interpreter.Options {
        val options = Interpreter.Options()
        
        // 1. Usar GPU delegate cuando esté disponible
        if (isGPUSupported()) {
            options.addDelegate(GpuDelegate())
        }
        
        // 2. Configurar número de threads
        options.setNumThreads(Runtime.getRuntime().availableProcessors())
        
        // 3. Usar NNAPI para hardware acceleration
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            options.setUseNNAPI(true)
        }
        
        return options
    }
    
    fun setupAudioOptimizations(context: Context) {
        val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
        
        // Configurar low-latency audio
        val sampleRate = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE).toInt()
        val bufferSize = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER).toInt()
        
        // Usar estos valores para optimizar el audio pipeline
    }
    
    private fun isGPUSupported(): Boolean {
        return try {
            val delegate = GpuDelegate()
            delegate.close()
            true
        } catch (e: Exception) {
            false
        }
    }
}

Gestión de Language Packs Offline

iOS: Descarga y Gestión de Modelos

class LanguagePackManager {
    static let shared = LanguagePackManager()
    private let userDefaults = UserDefaults.standard
    
    func downloadLanguagePack(locale: Locale, completion: @escaping (Bool) -> Void) {
        let recognizer = SFSpeechRecognizer(locale: locale)
        
        recognizer?.supportsOnDeviceRecognition == true ? completion(true) : completion(false)
    }
    
    func availableLanguages() -> [Locale] {
        return SFSpeechRecognizer.supportedLocales().filter { locale in
            let recognizer = SFSpeechRecognizer(locale: locale)
            return recognizer?.supportsOnDeviceRecognition == true
        }
    }
    
    func setPreferredLanguage(_ locale: Locale) {
        userDefaults.set(locale.identifier, forKey: "preferred_voice_language")
    }
}

Android: Language Pack Management

class AndroidLanguagePackManager(private val context: Context) {
    private val speechClient = Speech.getClient()
    
    fun downloadLanguagePack(languageTag: String, callback: (Boolean) -> Unit) {
        val downloadOptions = Speech.RemoteModelDownloadOptions.builder()
            .setLanguageTag(languageTag)
            .build()
            
        speechClient.downloadRemoteModel(downloadOptions)
            .addOnSuccessListener { callback(true) }
            .addOnFailureListener { callback(false) }
    }
    
    fun getDownloadedLanguages(): List<String> {
        // En production, implementar check de modelos descargados
        val prefs = context.getSharedPreferences("language_packs", Context.MODE_PRIVATE)
        return prefs.getStringSet("downloaded_languages", emptySet())?.toList() ?: emptyList()
    }
    
    fun isLanguageAvailable(languageTag: String): Boolean {
        return getDownloadedLanguages().contains(languageTag)
    }
}

Casos de Uso Reales y Métricas

Leonard AI: Asistente para Centros de Estética

Problema: Esteticistas necesitaban buscar tratamientos, clientes y agenda sin tocar pantallas (manos ocupadas durante tratamientos).

Solución: Asistente de voz on-device con comandos específicos del dominio.

// Comandos específicos entrenados
enum MedicalVoiceCommand {
    case findClient(name: String)
    case searchTreatment(type: String)
    case checkAppointments(date: Date)
    case addNote(clientId: String, note: String)
}

// Intent model entrenado con ~5K samples del dominio
class MedicalIntentClassifier {
    private let medicalTerms = ["botox", "relleno", "láser", "limpieza facial", "peeling"]
    
    func classify(_ text: String) -> MedicalVoiceCommand? {
        let lowercased = text.lowercased()
        
        if lowercased.contains("buscar cliente") || lowercased.contains("encontrar cliente") {
            let name = extractClientName(from: text)
            return .findClient(name: name)
        }
        
        if medicalTerms.contains(where: { lowercased.contains($0) }) {
            let treatment = extractTreatment(from: text)
            return .searchTreatment(type: treatment)
        }
        
        return nil
    }
}

Métricas Post-Implementación:

  • Adoption rate: 73% de esteticistas lo usan diariamente
  • Query success: 89% de comandos procesados correctamente
  • Time saving: 45 segundos promedio por búsqueda vs 2.1 minutos manual
  • User satisfaction: 4.6/5 (feedback interno)

ChutApp: Asistente de Estadísticas de Fútbol

Problema: Entrenadores querían consultar estadísticas mientras observaban entrenamientos (sin mirar móvil).

Implementación Android:

enum FootballVoiceCommand {
    case PlayerStats(playerName: String, metric: String)
    case TeamComparison(team1: String, team2: String)
    case MatchHistory(teamName: String, limit: Int)
}

class FootballIntentProcessor {
    private val footballTerms = mapOf(
        "goles" to "goals",
        "asistencias" to "assists", 
        "tarjetas" to "cards",
        "partidos" to "matches"
    )
    
    fun processFootballCommand(text: String): FootballVoiceCommand? {
        val normalized = text.lowercase()
        
        when {
            normalized.contains("estadísticas de") -> {
                val playerName = extractPlayerName(text)
                val metric = extractMetric(text)
                return FootballVoiceCommand.PlayerStats(playerName, metric)
            }
            
            normalized.contains("comparar") -> {
                val teams = extractTeamNames(text)
                return FootballVoiceCommand.TeamComparison(teams.first, teams.second)
            }
            
            else -> return null
        }
    }
}

Resultados:

  • Response time: 340ms promedio (incluyendo query a BD local)
  • Accuracy: 84% en reconocimiento de nombres de jugadores
  • Usage: 2.3K consultas/semana en temporada alta

Errores Comunes y Debugging

iOS: Speech Framework Gotchas

// ❌ ERROR: No verificar disponibilidad on-device
func badSpeechSetup() {
    let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
    // Esto puede fallar silenciosamente si no hay soporte on-device
}

// ✅ CORRECTO: Verificar y fallback
func goodSpeechSetup() -> SFSpeechRecognizer? {
    guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES")) else {
        return nil
    }
    
    // Verificar soporte on-device
    if #available(iOS 13.0, *), recognizer.supportsOnDeviceRecognition {
        return recognizer
    } else {
        // Fallback a cloud o mostrar error
        return nil
    }
}

// ❌ ERROR: No limpiar audio session
func badAudioCleanup() {
    audioEngine.stop()  // Esto deja el audio session en estado inconsistente
}

// ✅ CORRECTO: Limpiar completamente
func goodAudioCleanup() {
    audioEngine.stop()
    audioEngine.inputNode.removeTap(onBus: 0)
    recognitionRequest?.endAudio()
    recognitionTask?.cancel()
    
    // Resetear audio session
    try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}

Android: ML Kit Debugging

// ❌ ERROR: No manejar offline mode
class BadSpeechRecognizer {
    fun startListening() {
        val recognizer = Speech.getClient()  // Usa cloud por defecto
        recognizer.startListening()
    }
}

// ✅ CORRECTO: Configurar explícitamente on-device
class GoodSpeechRecognizer {
    fun startListening() {
        val options = Speech.SpeechRecognizerOptions.builder()
            .setOnDeviceRecognitionEnabled(true)
            .setLanguageTag("es-ES")
            .build()
            
        val recognizer = Speech.getClient(options)
        
        // Verificar que language pack está descargado
        if (!isLanguagePackAvailable("es-ES")) {
            downloadLanguagePack("es-ES")
            return
        }
        
        recognizer.startListening()
    }
}

// Debugging común: Logs estructurados
class VoiceAssistantDebugger {
    fun logSpeechEvent(event: String, metadata: Map<String, Any>) {
        val debugInfo = mapOf(
            "timestamp" to System.currentTimeMillis(),
            "event" to event,
            "device_info" to getDeviceInfo(),
            "memory_usage" to getMemoryUsage()
        ) + metadata
        
        Log.d("VoiceAssistant", debugInfo.toString())
    }
}

Consideraciones de Privacidad y Compliance

GDPR y Audio Processing

class VoicePrivacyManager {
    static func requestAudioPermission() -> Bool {
        // 1. Explicar claramente que audio no sale del dispositivo
        let alert = UIAlertController(
            title: "Permisos de Micrófono",
            message: "Tu voz se procesa completamente en tu dispositivo. Nunca se envía a servidores externos.",
            preferredStyle: .alert
        )
        
        // 2. Mostrar dónde revisar que on-device está activo
        // 3. Dar opción de deshabilitar fácilmente
        
        return true
    }
    
    static func showPrivacyIndicator() {
        // Mostrar indicador visual cuando se está grabando
        // Similar al indicador de FaceTime/Camera de iOS
    }
}

Almacenamiento de Audio

class AudioPrivacyManager {
    
    // ❌ NUNCA hacer esto
    fun badAudioStorage(audioData: ByteArray) {
        val file = File(context.filesDir, "audio_${System.currentTimeMillis()}.wav")
        file.writeBytes(audioData)  // Viola privacidad
    }
    
    // ✅ CORRECTO: Procesar y descartar
    fun goodAudioProcessing(audioData: ByteArray) {
        val transcript = processAudioOnDevice(audioData)
        // audioData se descarta automáticamente al salir del scope
        
        // Solo almacenar transcript si usuario da consent explícito
        if (userConsentedToTranscriptStorage()) {
            storeTranscript(transcript)
        }
    }
}

Monitorización y Métricas

iOS: Performance Tracking

class VoicePerformanceTracker {
    private var startTime: CFTimeInterval = 0
    
    func trackSpeechRecognitionLatency() {
        startTime = CACurrentMediaTime()
    }
    
    func reportLatency(event: String) {
        let latency = CACurrentMediaTime() - startTime
        
        // Log structured metrics
        let metrics = [
            "event": event,
            "latency_ms": Int(latency * 1000),
            "device": UIDevice.current.model,
            "ios_version": UIDevice.current.systemVersion
        ]
        
        // Enviar a analytics (sin datos sensibles)
        AnalyticsManager.track("voice_performance", parameters: metrics)
    }
    
    func trackModelPerformance() {
        let memoryUsage = getMemoryUsage()
        let cpuUsage = getCPUUsage()
        
        if memoryUsage > 500_000_000 {  // 500MB
            // Alertar sobre high memory usage
            Logger.warning("High memory usage in voice processing: \(memoryUsage)")
        }
    }
}

Android: Metrics Collection

class VoiceMetricsCollector {
    private val performanceMetrics = mutableMapOf<String, Long>()
    
    fun trackInferenceTime(modelType: String, duration: Long) {
        performanceMetrics["${modelType}_inference_ms"] = duration
        
        // Alert si inference > 500ms
        if (duration > 500) {
            Log.w("VoiceMetrics", "Slow inference for $modelType: ${duration}ms")
        }
    }
    
    fun trackAccuracy(expectedIntent: String, actualIntent: String) {
        val isCorrect = expectedIntent == actualIntent
        
        // Enviar métrica agregada (sin datos de usuario)
        val metrics = mapOf(
            "accuracy" to if (isCorrect) 1.0 else 0.0,
            "model_version" to BuildConfig.MODEL_VERSION,
            "device_tier" to getDeviceTier()
        )
        
        FirebaseAnalytics.getInstance(context).logEvent("voice_accuracy", Bundle().apply {
            metrics.forEach { (key, value) -> 
                when (value) {
                    is Double -> putDouble(key, value)
                    is String -> putString(key, value)
                }
            }
        })
    }
}

El Futuro: 2026 y Más Allá

Apple Intelligence + Local LLMs

Apple está integrando LoRA (Low-Rank Adaptation) en CoreML, permitiendo especializar modelos base para dominios específicos sin explotar la memoria. Esto significa que puedes tener un asistente médico, legal o técnico corriendo completamente on-device.

OpenJarvis Framework

Stanford lanzó OpenJarvis, un framework local-first para building personal AI agents con tools, memory y learning. Es la evolución natural de lo que estamos construyendo: asistentes que aprenden y se adaptan sin enviar datos a la nube.

Whisper on Mobile

OpenAI está working en versiones optimizadas de Whisper para mobile. Esperamos versiones quantizadas que corran a <100ms en iPhone 15+ y Pixel 8+.

Conclusión: La Era Post-Cloud de Voice AI

Después de migrar tres apps de producción a voice processing on-device, la conclusión es clara: el futuro es local. No solo por privacidad o latencia, sino por la experiencia de usuario.

Cuando tu asistente responde en <200ms, sin necesidad de internet, y nunca compromete tu privacidad, la adopción se dispara. En Leonard AI vimos un 73% de adoption rate vs <20% cuando usábamos cloud APIs.

Próximos Pasos

  1. Experimenta con los code samples de este post
  2. Entrena modelos específicos para tu dominio
  3. Mide latencia y accuracy en dispositivos reales
  4. Itera basándote en feedback de usuarios reales

La revolución de voice AI on-device ya está aquí. Los que se adapten primero van a dominar la experiencia móvil de los próximos años.


¿Implementando asistentes de voz en tu app? Comparte tu experiencia en los comentarios. ¿Qué challenges específicos estás enfrentando con speech recognition on-device?