EdgeMind — Una alternativa privada al Asistente de Google

active
Android Kotlin Gemma 4 E2B LiteRT-LM Audio nativo Tool calling IA on-device
EdgeMind — Una alternativa privada al Asistente de Google

EdgeMind

EdgeMind es un asistente de voz Android que corre entero en el dispositivo — sin nube, sin cuenta, sin telemetría. Mantienes pulsado un botón, le hablas, y Gemma 4 — corriendo en local — o te responde directamente o llama a una herramienta (timer, calendario, control de música, búsqueda web, linterna…). Tu voz nunca sale del móvil.

El modelo en el centro es Gemma 4 E2B con audio nativo de entrada — la variante multimodal de ~2 B de parámetros efectivos de Google, pensada para móvil. Se carga a través de LiteRT-LM (el runtime on-device de Google), con los bytes del micro entrando directos al modelo y las llamadas a herramientas despachadas automáticamente vía la capa de reflexión de litertlm.

Por qué existe

Hay asistentes de voz open source en Android — Dicio es el referente y es un proyecto realmente bueno. Pero Dicio (y los hermanos tipo Rhasspy de antes) está construido alrededor de gramáticas de comandos y plugins de skills: un pattern matcher fijo con handlers cableados. Eso va de maravilla para “pon un timer de 10 minutos” y se cae a pedazos en cuanto el usuario formula algo que la gramática no anticipó.

Yo quería construir la versión donde un LLM hace el razonamiento en lugar de un parser, para que el mismo asistente pueda manejar “qué tengo en el calendario”, “recuérdame sacar el pollo en 25 minutos” y “qué pasó ayer en la F1” sin que nadie tenga que enumerar los patrones por adelantado. Con los LLMs on-device por fin lo bastante rápidos en móviles modernos — y Gemma 4 trayendo audio nativo así que ya ni siquiera necesitas un STT por separado — eso es viable. EdgeMind es lo que sale de hacerlo así.

El enfoque de privacidad tampoco es decorativo. Un asistente donde cada prompt queda grabado en una empresa de publicidad es un producto distinto a uno que funciona solo en el dispositivo. El Asistente de Google envía tu audio a un servidor. EdgeMind no.

Cómo funciona

El modelo. Gemma 4 E2B con audio nativo, distribuido como un fichero .litertlm (~2,5–3 GB). Se descarga del litert-community de HuggingFace en la primera ejecución con resume HTTP por rangos, no va dentro del APK. Con audio nativo, no hay un paso aparte de Whisper/STT — los bytes del micro entran directamente al modelo junto al system prompt, y Gemma transcribe-y-razona en una sola pasada.

El runtime — y por qué no MediaPipe. Empecé con 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 está fijada a CPU en los metadatos del .litertlm, pero el ejecutor de audio de tasks-genai está cableado a GPU only y revienta intentando materializar LlmParameters. La solución fue bajar una capa a LiteRT-LM (com.google.ai.edge.litertlm:litertlm-android:0.10.2), que expone backends por modalidad — LLM en GPU, audio en CPU — que es lo que el modelo realmente exige. La API en Kotlin en sí está suficientemente documentada (Engine, ConversationConfig, ToolSet / @Tool / @ToolParam); lo que no está documentado es la capa operativa de debajo.

El cableado del audio (el truco de la cabecera WAV). El micro produce PCM crudo de 16 kHz mono 16-bit a través de AudioRecord — exactamente el formato que el modelo espera. Pero meter esos bytes crudos en LiteRT-LM lanza INTERNAL: Failed to initialize miniaudio decoder, error code: -10. LiteRT-LM decodifica audio vía miniaudio, que olfatea el formato a partir de la cabecera — y el PCM crudo no tiene ninguna. La solución es una cabecera RIFF/WAVE de 44 bytes prefijada en proceso; los 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 inicializar el engine. El System.loadLibrary("OpenCL") de Android solo busca en el namespace estándar del linker y se pierde el OpenCL instalado por el fabricante en la mayoría de Mali/Adreno, así que pruebo manualmente las rutas absolutas comunes (/vendor/lib64/, /system/vendor/lib64/, etc.). Si un backend muere a mitad de generación, queda marcado como malo, se cierra el engine, y el turno siguiente reinicializa con el siguiente mejor backend. El audio siempre queda pinneado a CPU — esa sección del model card lo exige.

Tool calling. Cada herramienta (poner un timer, leer o crear eventos de calendario, controlar música, buscar contactos, encender/apagar linterna, cambiar volumen, buscar en web, abrir apps) es una función Kotlin en un ToolSet anotada con @Tool / @ToolParam, registrada en Hilt como un set multibound. Al arrancar la conversación, todos los ToolSets se pasan a litertlm con automaticToolCalling=true, y la JNI gestiona el bucle de forma nativa: el modelo emite una llamada a herramienta → la JNI reflexiona sobre la función @Tool → el resultado vuelve al modelo → la generación continúa. Desde la app es simplemente un stream de tokens Content.Text.

Voz entrada / voz salida. Solo push-to-talk hoy — mantienes pulsado el botón del micro, hablas, sueltas. AudioRecord captura a 16 kHz mono con tope de 30 s coincidiendo con el segmento de audio máximo del modelo, y la respuesta de Gemma se canaliza por el motor TextToSpeech de Android. El system prompt se reconstruye cada turno con la fecha, hora y zona horaria reales — sin eso, el modelo se inventa fechas a partir de su corte de entrenamiento de 2023.

Lo que funciona hoy

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

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

Todo eso con audio capturado en local, sin viaje de ida y vuelta a un STT, y 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 la primera ejecución desde litert-community/gemma-4-E2B-it-litert-lm)
  • LiteRT-LM 0.10.2 (com.google.ai.edge.litertlm:litertlm-android) — backends por modalidad
  • TextToSpeech de Android para las respuestas · AudioRecord para el micro (PCM 16 kHz mono, envuelto en WAV antes de pasarlo)
  • 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: domain · data · presentation

Cosas que me gustan

Elegir el runtime correcto. Tanto MediaPipe tasks-genai como LiteRT-LM están documentados y cargan Gemma 4 en el camino feliz. Ninguna documentación te dice que el adaptador de audio de Gemma 4 E2B está fijado a CPU en el .litertlm y que el ejecutor de audio de tasks-genai está cableado a GPU. Eso es algo que encuentras leyendo el modo de fallo y cruzándolo con lo que el runtime realmente expone. LiteRT-LM te deja fijar el backend de audio por modalidad; es el único camino que funciona hoy.

El workaround 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. El arreglo más pequeño para el desbloqueo más grande.

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

Tool calling sin bucle de herramienta. litertlm corre la danza de llamada → ejecutar → continuar nativamente vía reflexión sobre las funciones Kotlin con @Tool. La app solo define herramientas y consume tokens en streaming; el bucle del asistente es problema del runtime, no mío.

Sin nube, sin cuenta, sin telemetría. Cae solo desde la arquitectura, no es un eslogan de marketing. No hay dependencia server-side que se pueda romper.

Hacia dónde va

Lo difícil ya funciona: Gemma 4 carga, el audio entra, las herramientas se despachan, las respuestas salen por el altavoz — todo en push-to-talk de momento. 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 detrás de un VAD on-device para quien prefiera no mantener pulsado un botón.

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