Activo 2025

EdgeMind — Asistente de voz en el dispositivo

Un asistente de voz para Android que se ejecuta por completo en el dispositivo. Gemma 4 E2B con audio nativo se encarga del razonamiento; las herramientas se invocan por reflexión. Tu voz nunca sale del teléfono.

Año

2025

Stack

AndroidKotlinGemma 4 E2BLiteRT-LMAudio nativoTool callingIA on-device

Enlaces

EdgeMind es un asistente de voz para Android que se ejecuta por completo en el dispositivo: sin nube, sin cuenta, sin telemetría. Mantienes pulsado un botón, hablas, y un modelo que corre en local te responde o invoca una herramienta (temporizador, calendario, control de música, búsqueda web, linterna…). Tu voz nunca sale del teléfono.

Por qué existe

Ya existen asistentes de voz de código abierto para Android. Dicio es el ejemplo obvio y un proyecto seriamente bueno. Pero Dicio se construye sobre gramáticas de comandos y plugins de habilidades: un reconocedor de patrones fijo con manejadores cableados. Eso funciona muy bien para “pon un temporizador de diez minutos” y se desmorona en cuanto el usuario formula algo que la gramática no preveía.

El objetivo era la versión en la que un LLM hace el razonamiento en lugar de un parser, de modo que el mismo asistente pueda gestionar "¿qué tengo en el calendario?", “recuérdame sacar el pollo dentro de 25 minutos” y "¿cuál fue el resultado de la F1 ayer?" sin que nadie tenga que enumerar los patrones de antemano. Con los LLM on-device por fin lo suficientemente rápidos en teléfonos modernos, y con Gemma 4 incluyendo audio nativo de modo que ni siquiera hace falta un STT aparte, eso es viable. EdgeMind es lo que surge de hacerlo así.

El enfoque de privacidad tampoco es decorativo. Un asistente que graba cada prompt en un servidor de publicidad es un producto distinto a uno que funciona solo en el dispositivo. Google Assistant envía tu audio a un servidor. EdgeMind, no.

Cómo evolucionó

EdgeMind no siempre fue Gemma 4. Pasó por tres fases distintas, y cada una muestra lo implacable que es el ML en móvil.

Empezó como un ambicioso asistente para Android Auto: Whisper para STT, Llama 3.2 para razonamiento, Kokoro para TTS. La ejecución simultánea de varios modelos en un teléfono resultó excesiva; de ahí, reducción del problema a una sola cuestión: ¿se puede ejecutar un LLM bien en Android? El resultado: una implementación desde cero sobre Phi-3 mini con ONNX Runtime.

Esa fase fue donde ocurrió el aprendizaje real. La cuantización INT4 llevó a Phi-3 de 7,6 GB a 2,6 GB sin pérdida apreciable para chat. El tokenizer no se podía importar (ONNX Runtime GenAI no está en Maven y habría añadido más de 50 MB), de modo que la solución pasó por la implementación manual del tokenizer SentencePiece BPE de Phi-3, parseando tokenizer.json en 32.000 tokens base y 61.249 reglas de fusión. Trescientas cincuenta líneas de Kotlin, cero dependencias, y el tipo de comprensión que solo se obtiene construyendo la cosa uno mismo.

El mayor salto fue el KV cache. Sin él, cada token nuevo reprocesa todo lo anterior, un coste O(n²) con el token 50 tardando treinta segundos. Cachear los tensores de atención key/value redujo eso a ~150 ms por token, aproximadamente 6× más rápido. Y el bug más caro fue una fuga de memoria escondida en ese mismo cache: liberaba los tensores viejos antes de crear los nuevos, así que 64 tensores × 200 iteraciones fugaban hasta que el OOM killer mataba el proceso. Un único reorden lo resolvió: crear los nuevos, luego cerrar los viejos. El ML en móvil no es como en escritorio; un close() olvidado mata la app a los dos minutos.

NNAPI sumó otro 4× gracias a la NPU. Combinado, la generación pasó de ~800 ms/token en CPU a ~200 ms, una mejora de 24× sobre la base ingenua. Esa rama de Phi-3 es estable en producción; el artículo profundiza en el tokenizer y en las interioridades del cache.

El salto a Gemma 4 E2B con audio nativo es lo que hizo posible un asistente de voz real. Sin STT aparte, los bytes de audio entran directamente al modelo. Esa es la arquitectura de abajo.

Cómo funciona

El modelo. Gemma 4 E2B con audio nativo, distribuido como archivo .litertlm (~2,5–3 GB). Se descarga de litert-community en HuggingFace en el primer arranque con reanudación HTTP Range, sin empaquetarse en el APK. Con audio nativo no hay etapa Whisper/STT. Los bytes del micro entran directamente al modelo junto con el system prompt, y Gemma transcribe y razona en una sola pasada.

El runtime, y por qué no MediaPipe. El punto de partida fue com.google.mediapipe:tasks-genai, la opción obvia. Se negó a cargar Gemma 4 con audio: la sección del adaptador de audio del modelo tiene CPU fijada en los metadatos del .litertlm, pero el executor de audio de tasks-genai tiene la GPU cableada por defecto y muere al materializar LlmParameters. La solución fue bajar un nivel a LiteRT-LM (com.google.ai.edge.litertlm:litertlm-android:0.10.2), que expone backends por modalidad (GPU para el LLM, CPU para el audio), justo lo que el modelo requiere.

El cableado de audio (el truco de la cabecera WAV). El micro produce PCM mono de 16 bits a 16 kHz en bruto a través de AudioRecord, exactamente el formato que espera el modelo. Pero pasar esos bytes en bruto a LiteRT-LM lanza INTERNAL: Failed to initialize miniaudio decoder, error code: -10. LiteRT-LM decodifica el audio con miniaudio, que deduce el formato a partir de la cabecera, y el PCM en bruto no la tiene. La solución es prefijar una cabecera RIFF/WAVE de 44 bytes en proceso; mismos bytes, miniaudio reconoce el formato, el modelo recibe su audio. Ese único truco fue la diferencia entre “el audio funciona” y “el audio es imposible”.

Cascada de backends. GPU (OpenCL) → GpuArtisan → CPU, probados en orden al iniciar el motor. El System.loadLibrary("OpenCL") de Android solo busca en el namespace estándar del linker y no ve el OpenCL instalado por el fabricante en la mayoría de dispositivos Mali/Adreno, lo que obliga a un sondeo explícito de las rutas absolutas habituales (/vendor/lib64/, /system/vendor/lib64/, …). Si un backend muere a mitad de generación se marca como malo, se cierra el motor y el siguiente turno se reinicia con el siguiente mejor backend. El audio siempre queda fijado a CPU; esa sección de la tarjeta del modelo lo exige.

Llamadas a herramientas. Cada herramienta (poner un temporizador, leer o crear eventos de calendario, controlar la música, buscar contactos, conmutar la linterna, cambiar el volumen, buscar en la web, abrir apps) es una función Kotlin en un ToolSet anotada con @Tool / @ToolParam, registrada en Hilt como un conjunto multibound. Al iniciar la conversación todos los ToolSet se pasan a litertlm con automaticToolCalling=true, y el JNI gestiona el bucle de forma nativa: el modelo emite una llamada → el JNI reflexiona en la función @Tool → el resultado vuelve → la generación continúa. Desde el lado de la app es solo un flujo de tokens Content.Text.

Voz de entrada / salida. Por ahora solo push-to-talk: mantén pulsado el botón del micro, habla, suelta. AudioRecord captura a 16 kHz mono con un límite de 30 s que coincide con el segmento máximo de audio del modelo, y la respuesta de Gemma se redirige al TextToSpeech de Android. El system prompt se reconstruye en cada turno con la fecha, hora y zona actuales. Sin eso, el modelo inventa fechas a partir de su corte de entrenamiento de 2023.

Qué funciona hoy

Bucle de voz de extremo a extremo con llamadas reales a herramientas:

  • "¿Qué tengo en el calendario mañana?" → lee los próximos eventos del calendario del sistema.
  • “Añade un evento para cenar con Marina a las 8 esta noche.” → escribe en el calendario principal con la zona horaria correcta.
  • “Enciende la linterna.” → conmuta el flash de la cámara.
  • “Busca en la web la clasificación de la F1.” → abre el navegador con la consulta.
  • Más temporizadores, volumen, transporte de música (play/pausa/sig./ant.), now-playing y apertura de apps instaladas por nombre.

Todo ello con el audio capturado en local, sin viaje de ida y vuelta del STT, y con el modelo decidiendo cuándo llamar a una herramienta y cuándo responder directamente.

Stack

  • Kotlin 1.9.20 · min SDK 26 (Android 8.0) · target SDK 34 (Android 14)
  • Gemma 4 E2B con audio nativo (.litertlm, descargado en el primer arranque desde litert-community/gemma-4-E2B-it-litert-lm)
  • LiteRT-LM 0.10.2, backends por modalidad
  • TextToSpeech de Android para las respuestas · AudioRecord para el micro (PCM mono a 16 kHz, envuelto en WAV antes de entregarlo)
  • Hilt 2.57 para DI · ToolSets multibound para las herramientas
  • Jetpack Compose + Material 3 · Coroutines + Flow para streaming
  • Room para el historial de conversación
  • Clean Architecture: dominio · datos · presentación

Qué funcionó

Elegir el runtime correcto. Tanto MediaPipe tasks-genai como LiteRT-LM están documentados y cargan Gemma 4 en el happy path. Ningún documento dice que el adaptador de audio de Gemma 4 E2B tiene la CPU fijada en el .litertlm y que el executor de audio de tasks-genai tiene la GPU cableada. Esa es la conclusión al leer el modo de fallo y cruzarlo con lo que el runtime realmente expone. LiteRT-LM permite fijar el backend de audio por modalidad; es el único camino que funciona hoy.

La solución de la cabecera WAV. Tres líneas de ByteBuffer y una cabecera RIFF/WAVE/fmt /data prefijada al PCM, y la entrada de audio pasa de “miniaudio error -10” a funcionar de extremo a extremo. La solución más pequeña para el mayor desbloqueo.

Cascada de backends con sondeo del OpenCL del fabricante. GPU → GpuArtisan → CPU, con dlopen manual contra las siete rutas habituales de fabricante en Android porque el namespace del linker del sistema no las ve. Si un backend falla a mitad de generación se cuarentena y el siguiente turno cae a la siguiente opción, de forma transparente para el usuario.

Llamadas a herramientas sin bucle de herramientas. litertlm ejecuta la danza llamada → ejecución → continuación de forma nativa mediante reflexión sobre funciones Kotlin anotadas con @Tool. La app solo define herramientas y emite tokens; el bucle del asistente es problema del runtime, no de la app.

Sin nube, sin cuenta, sin telemetría. Se desprende de la arquitectura, no de un eslogan de marketing. No hay ninguna dependencia del lado del servidor que pueda romperse.

Hacia dónde va

Las partes difíciles ya funcionan: Gemma 4 carga, el audio entra, las herramientas se invocan, las respuestas salen por el altavoz, todo por push-to-talk por ahora. Lo que queda es la capa de pulido: registrar EdgeMind como asistente por defecto del dispositivo vía RoleManager.ROLE_ASSISTANT y un VoiceInteractionService, y luego un modo manos libres con VAD on-device para quienes prefieran no mantener pulsado un botón.

Código: github.com/IgnacioLD/edgemind.