App Android para Ray-Ban Meta (Gen 1 y Gen 2) que hace lo que la app oficial no deja:
- Grabar sin el límite de 5 minutos — ✅ verificado en hardware real, 6m56s
- Transmitir a TikTok, YouTube, Twitch o cualquier RTMP — ⏳ escrito y compilado, nunca probado contra un servidor
- Modos de captura que la app oficial no tiene: timelapse, dashcam, lectura — ⏳ sin probar en hardware
- Formas de salida: 9:16 nativo, 4:5, 1:1, 16:9 (recorte o franjas), en grabación y en vivo — ⏳ sin probar en hardware
- Solo audio: grabar con los micrófonos de los lentes sin encender la cámara — ✅ verificado en hardware, 8 kHz mono
- Archivo: la lista de lo grabado (videos y audios) dentro de la propia app — ✅ verificado en hardware
Construida sobre el Meta Wearables Device Access Toolkit (DAT) v0.9.0, el SDK oficial. Sin reversing, sin firmware modificado, sin nada que pueda brickear los lentes.
Se llamaba GlassCast. Cambió a LePe el 25-ago-2026, y el cambio es solo de cara: el paquete sigue siendo
com.rbmeta.glasscast, el deep link sigue siendoglasscast://, y las clases siguen llamándoseGlassCastScreen,GlassCastApp. Renombrar eso obligaría a re-registrar la app en el Developer Center para nada. Los clips grabados antes de ese día siguen enPelículas/GlassCast; MediaStore no mueve archivos solos.
Estado: v0.11.1 — Wi‑Fi Direct probado y concedido, sin ganancia de calidad. Meta AI reconoce a LePe como app de confianza con
allowWiFiDirect=true; el stream conmutó a Wi‑Fi en 3 s sobre la misma conexión, pero el video siguió en ~495 kbps (720p, con 2000 pedidos y la escalera reescrita) y con ráfagas de corrupción cada ~15 s. El tope de 500 kbps es del codificador de los lentes para apps de terceros, sea cual sea el transporte. Ver Wi‑Fi como transporte. Lo anterior: v0.10.3 — el tope de 500 kbps del video es del firmware de los lentes: probadas cuatro perillas por la puerta interna del SDK (bitrate 1200/2000, escalera ABR reescrita, ABR de los lentes apagado) y las cuatro dieron ~500 kbps; la última además rompe el primer GOP. Detalle en El tope de 500 kbps. Lo anterior: v0.9.0 — "Audio por el stream" (experimental): el SDK de Meta lleva dentro un canal de audio AAC 44.1 kHz estéreo por la misma tubería que el video, que su API pública nunca enciende; LePe lo enciende por una puerta interna (ver Audio por el stream). Verificado en hardware el 5-sep-2026: el clip salió con AAC 44.100 Hz estéreo a 95 kbps, contenido hasta ~14 kHz y canales distintos; el video mantuvo sus 500 kbps con el audio encima (el enlace dio ~595 en total). Lo anterior: v0.8.0 — el modo solo audio afinado como micrófono de solapa (ganancia y bitrate medidos), y "prestar el micrófono a otras apps" para que la cámara nativa grabe con los lentes: construido, instalado y sin verificar — es la primera prueba pendiente. Lo anterior: v0.7.0 — ~9,500 líneas en 35 archivos Kotlin, instalada en el S25 Ultra. Solo audio y la pestaña Archivo verificados en hardware; el micrófono de los lentes funciona (8 kHz mono por HFP, medido). El estéreo de dos micrófonos está verificado como imposible en este teléfono (trampa #25, medido dos veces): el sistema sirve el micrófono de los lentes a ambas capturas y la API miente al preguntarle. El default del modo solo audio es el micrófono del teléfono;StereoSentineldetecta la colisión escuchando el propio sonido. v0.7.0 añade la petición de exención del ahorro de batería para que las grabaciones sobrevivan con la pantalla bloqueada — pedirla es lo primero al retomar.
| Herramienta | Versión | Ubicación |
|---|---|---|
| JDK | Temurin 17.0.20.1 | %LOCALAPPDATA%\Programs\devtools\jdk-17.0.20.1+1 |
| Android SDK | platform 37, build-tools 37 + 35, platform-tools 37.0.1 | %LOCALAPPDATA%\Android\Sdk |
| Android Studio | Quail 3 (2026.1.3 P1) | %LOCALAPPDATA%\Programs\android-studio |
JAVA_HOME, ANDROID_HOME, ANDROID_SDK_ROOT y PATH están puestos a nivel de usuario. Hay acceso directo en el escritorio.
El SDK desapareció el 26-ago-2026 y hubo que reinstalarlo por consola (command-line tools →
platforms/android-37.0,build-tools/37.0.0,platform-tools). Si vuelve a pasar, hay una trampa: Google publica ahora la API 37 comoplatforms/android-37.0, conAndroidVersion.ApiLevel=37.0en su ficha, y AGP 8.13.2 busca el hashandroid-37exacto — falla conFailed to find target with hash string 'android-37'. La API 37 tampoco existe en el repositorio antiguo (llega hasta android-36), así que AGP no la puede descargar sola. Arreglo: copiarplatforms\android-37.0aplatforms\android-37y en la copia ponerAndroidVersion.ApiLevel=37(sin el.0). El original se deja intacto.
./gradlew :app:assembleDebugCon el teléfono conectado por USB y depuración activada:
adb install -r app/build/outputs/apk/debug/app-debug.apkEl APK también se copia a C:\Users\pedro\OneDrive\LePe.apk (y a LePe-<versión>.apk) para compartirlo.
Subir
versionCodeyversionNameen CADA build que toque el teléfono. No es burocracia: ya se perdió una ronda entera de pruebas en hardware analizando dos clips que traían la firma exacta de un bug ya arreglado, porque el teléfono estaba corriendo un APK viejo y no había forma de saberlo. Por eso además la versión se muestra en la cabecera de la app y el APK compartido lleva el número en el nombre (el navegador cacheaLePe.apk, noLePe-0.5.0.apk).
Token de GitHub. El SDK de Meta está en GitHub Packages y exige autenticación aunque el paquete sea público. Vive en local.properties, fuera de git. Si se pierde: crear uno con scope read:packages en https://github.com/settings/tokens/new?scopes=read:packages y ponerlo en github_token=.
Developer Mode — son DOS toggles, y es lo que más falla:
Meta AI → Settings → App Info → tocar 5 veces la versión → Enable
Meta AI → Settings → Your glasses → Developer Mode → ON
Requiere app Meta AI v272+.
Quien la reciba puede instalar el APK sin nada de esto: ni Android Studio, ni token, ni compilar. Solo necesita sus propios lentes, un Android 12+ y los dos toggles de arriba.
Con una restricción documentada: "For security purposes, only one 3rd party app can remain registered at a time in Developer Mode". Instalar LePe desregistra cualquier otra app de terceros. La documentación no dice si eso se levanta en producción.
Excepción: el modo solo audio no necesita nada de esto — ni Developer Mode, ni registro, ni permiso de cámara. Basta con que los lentes estén emparejados por Bluetooth, porque ese camino no pasa por el SDK de Meta (ver abajo).
La sección más importante para retomar. No confundir "compila" con "funciona".
Grabación larga. Un clip de 6m56s en unos Gen 2 el 25-ago-2026, verificado parseando los átomos mvhd/stsd/stsz del MP4, no confiando en el reproductor:
DURACION: 6m 56.1s (416.1 s) 31.3 MB
hvc1 9,986 muestras de video → 24.0 fps exactos, cero frames perdidos
mp4a 17,907 muestras de audio
De ahí sale el consumo medido que alimenta todo el cálculo de autonomía: 4.51 MB/min a 24 fps.
Exportar a galería. El clip apareció en Movies/GlassCast/ (así se llamaba la carpeta entonces; hoy es Movies/LePe/) y abre desde Galería.
Recepción de video, preview, registro, permisos. Todo el camino DeviceSession → Camera → Stream funciona con los lentes reales (RB Meta XXXX).
Bocinas. Confirmado por dumpsys: los lentes están conectados como A2DP y son la salida de audio activa. El botón "Hablar" está implementado pero nadie confirmó haberlo oído — falta esa comprobación.
El micrófono de los lentes, por fin (26-ago-2026). Era la incógnita más vieja del proyecto y quedó resuelta con dos grabaciones de solo audio, verificadas parseando los átomos y midiendo el nivel con ffmpeg, no confiando en un reproductor:
lepe_audio_97542357.m4a 8,000 Hz mono 20.9 s mean -36.9 dB max -12.2 dB <- lentes (HFP)
lepe_audio_97499591.m4a 44,100 Hz mono 32.2 s mean -30.1 dB max -6.7 dB <- telefono
Las dos cosas que dice esa tabla:
- Entra sonido de verdad por el micrófono de los lentes. Silencio digital daría −91 dB o −∞; −36.9 dB de media con picos a −12.2 es voz.
- El enlace negoció banda estrecha: 8 kHz. La app pregunta por banda ancha (16 kHz, mSBC) y estos lentes con este teléfono no la ofrecieron. Es el techo real, no una elección de la app.
La grabación de los lentes sale ~7 dB por debajo de la del teléfono, coherente con el beamforming que aísla la voz y aplasta el ambiente.
La pestaña Archivo. Listó las 12 grabaciones (10 videos + 2 audios), incluidos los clips viejos de Películas/GlassCast, con duración correcta también en los .m4a: el escaneo del sistema sí la rellena al momento.
Primera prueba en hardware del dashcam. Salió mal de tres formas, y las tres están arregladas pero sin volver a probar:
| Síntoma | Causa real | Arreglo |
|---|---|---|
| El clip no se reproduce | Cabecera decía 720×1280, el video era 504×896 (trampa #13) | Dimensiones leídas del SPS |
| El clip no se reproduce (2) | Los frames que llegaban durante el volcado del buffer no los recogía nadie: el archivo salía con un agujero en la costura pasado/presente, y lo que venía después apuntaba a imágenes ausentes | DumpGate retiene el directo mientras se vuelca |
| Sin audio | withAudio = false clavado en saveDashcamClip |
Respeta el ajuste; el pasado va mudo y el mic entra desplazado por la duración de ese pasado |
Diagnóstico hecho parseando los átomos de los dos MP4 y decodificándolos con ffmpeg: 126 errores de tipo Could not find ref with POC en el clip del dashcam, cero en el clip normal. El clip normal medía 4.49 MB/min — clavado al baseline de 4.51.
| Qué | Riesgo |
|---|---|
RTMP (RtmpBroadcaster) |
Alto. Nunca tocó un servidor. La API de RootEncoder está verificada contra su código fuente, pero el flujo completo no. |
Dashcam (FrameRingBuffer) |
Medio-alto. El recorte por GOP es delicado: si falla, el MP4 sale indecodificable. |
| Transcodificación H.264 | Alto. El camino surface-a-surface no se ha ejercitado nunca. |
| Timelapse | Medio. La reescritura de tiempos es simple pero no se ha visto un archivo resultante. |
Formas de salida (FrameCropRenderer, FormatExporter) |
Alto. Todo el camino GL (EGL + SurfaceTexture + shader) nunca se ha ejecutado. Es código estándar de editor de video Android, pero estándar no es probado. |
Foto (capturePhoto) |
Bajo. |
| Solo audio en segundo plano | Medio. Grabar con la app en pantalla ya está verificado (arriba); lo que falta es con el teléfono guardado —ahí manda el tipo microphone del foreground service (trampa #24)— y qué pasa exactamente con una llamada entrante, que se queda con el canal SCO. |
¿Hacía falta el arreglo del MODE_IN_COMMUNICATION? No se sabe. El micrófono de los lentes se oyó por primera vez el 26-ago-2026, con ese arreglo ya puesto (trampa #22), así que no hay forma de saber si antes fallaba por eso o si nunca se había probado en serio. No merece la pena revertirlo para averiguarlo: el arreglo es correcto de todos modos.
El nivel térmico nunca reportó nada. En todas las sesiones ThermalLevel se quedó en UNKNOWN. No sabemos si los Gen 2 no lo reportan, si tarda mucho, o si hace falta más carga. Hay un log puesto (GlassesViewModel: DeviceState thermal=...) para averiguarlo. Si resulta que nunca reporta, hay que quitar ese techo del cálculo — hoy, siendo UNKNOWN, simplemente no impone límite, que es lo correcto mientras no se sepa.
Ray-Ban Meta ──HEVC/Bluetooth Classic──> Teléfono ──┬──> MP4 local (passthrough)
720p máx └──> RTMP → TikTok/YouTube/Twitch
El detalle que hace todo viable: con compressVideo = true el DAT entrega HEVC ya comprimido, no frames crudos. Grabar es entonces un remux — copiar bytes a un MP4 — en vez de un re-encode. Casi cero CPU y casi cero batería del teléfono.
Ese booleano no es opcional. Su default es false. Ver la trampa #1.
El dato que gobierna todo: el Bluetooth entrega un caudal fijo y el SDK lo reparte entre los frames que le pidas. Documentación de Meta: "pedir menor resolución, menor frame rate, o ambos, puede dar mejor calidad visual con menos pérdida por compresión".
O sea: bajar los fps no empeora la imagen, la mejora. Pero cuánto, es cosa de medirlo — ver abajo.
Dentro del SDK (internal/abr/FrameRateAdaptiveVideoConfigHandler) está cocida la escalera de bitrate que pide a los lentes:
| Resolución | Bitrate objetivo | fps |
|---|---|---|
| 720×1280 | 750–1200 kbps | 24 |
| 504×896 | 250–750 kbps | 24 |
| 360×640 | 100–250 kbps | 24 |
El enlace real entrega ~500 kbps (medido en tres clips distintos: 496, 497 y 497 kbps). Es decir: 720p pide un mínimo de 750 kbps y el Bluetooth da 500. Ese es el origen de "se ve borroso" — no es la compresión en abstracto, es que se piden más píxeles de los que caben en el caudal, y por eso además el ABR degrada solo a 504×896 en cuanto algo aprieta.
Medido el 25-ago-2026 con dos clips de la misma escena:
| Modo | Resolución | fps | kbps | KB/frame | bytes/píxel | MB/min |
|---|---|---|---|---|---|---|
| Video | 720×1280 | 24 | 497 | 2.5 | 0.0028 | 3.55 |
| Lectura | 504×896 | 2 | 64 | 3.9 | 0.0088 | 0.46 |
Corrección importante: leyendo el bytecode parecía que el bitrate era fijo por peldaño y que bajar a 2 fps daría 12× más datos por frame. Falso. El bitrate baja con los fps (497 → 64 kbps), y cada frame solo recibe 1.6× más. Lo que sí mejora 3.1× es el dato por píxel, y en ese clip parte del mérito es de la menor resolución, no de los fps.
De ahí sale la receta real, en orden de impacto:
- Pedir 504×896 en vez de 720×1280 — a igualdad de fps duplica los bytes por píxel, porque el mismo caudal se reparte entre la mitad de píxeles. Una imagen de 504 bien alimentada se ve más limpia que una de 720 hambrienta.
- Bajar los fps — ~1.6× más datos por frame de 24 a 2. Ayuda, pero menos de lo que promete la intuición.
- Cuidar el enlace — teléfono cerca y del mismo lado del cuerpo, sin otros dispositivos Bluetooth compitiendo. Y no tomar fotos mientras grabas: van por el mismo canal (trampa #15).
De paso, el modelo de autonomía quedó validado: predice 0.38 MB/min a 2 fps y se midieron 0.46 — 18% de error, aceptable para lo que es.
| Modo | fps | MB/min | Para qué |
|---|---|---|---|
| Video | 24 | 4.51 | Grabación continua |
| Timelapse | 2 | 0.38 | 1 hora en 2 minutos, con la mejor imagen posible |
| Dashcam | 15 | 2.82 | Buffer circular: guardas lo que YA pasó |
| Lectura | 2 | 0.38 | Texto legible: máxima nitidez por frame |
| Solo audio | — | 0.48 | Sin cámara: se cuenta en horas, no en minutos |
Los lentes entregan siempre vertical 9:16 — que ya es, píxel por píxel, el formato de Reels/TikTok/Shorts. Cualquier otra proporción se fabrica desde ese cuadro, y solo hay dos maneras: recorte central (llena el cuadro, tira imagen) o franjas (imagen completa entre barras negras). El precio de cada formato es cuánta imagen pierde:
| Formato | Se pierde | Para qué |
|---|---|---|
| 9:16 | 0% | Reels, TikTok, Shorts — gratis, es el nativo |
| 4:5 | 30% | Feed de Instagram |
| 1:1 | 44% | Cuadrado |
| 16:9 recorte | 68% | Horizontal que llena la pantalla (queda 720×404) |
| 16:9 franjas | 0% | Horizontal con la imagen completa entre barras (lienzo 1280×720) |
Dónde se paga el trabajo. El nativo es remux. Todo lo demás obliga a decodificar → recortar en GPU (FrameCropRenderer) → re-encodear:
- Grabación: el 9:16 se graba SIEMPRE tal cual (el camino gratis y verificado). Al detener,
FormatExporterrevela la variante como archivo aparte (H.264, para máxima compatibilidad al compartir). Si el proceso falla, el original queda intacto — como revelar copias desde un negativo. - En vivo: no hay negativo posible; el recorte se hace al vuelo dentro del transcoder. Un formato no nativo obliga a H.264 aunque esté elegido HEVC directo: un stream comprimido no se puede recortar sin abrirlo.
El DAT no da micrófono. Nada. Verificado descompilando los AAR de v0.9.0: la interfaz Stream tiene getVideoStream(), capturePhoto(), start(), stop() y sus estados, y el enum Permission del SDK tiene una sola entrada: CAMERA. Por esa vía no hay audio ni lo habrá hasta que Meta lo añada.
Pero el audio de los lentes nunca vino de ahí. Viene de Bluetooth HFP: para el teléfono, los Ray-Ban Meta son unos auriculares con micrófono, y grabar de ellos es Android puro (setCommunicationDevice() + AudioRecord). Es el mismo canal por el que hablas en una llamada.
La consecuencia es la que hace interesante el modo: una grabación de solo audio no necesita encender la cámara. Y sin cámara se cae, de golpe, casi todo lo que limita al resto de la app:
| Lo que limita al video | En solo audio |
|---|---|
| El enlace da ~500 kbps y 720p pide 750 | Irrelevante: el audio ocupa ~1% de eso |
| El SDK baja la resolución solo y congela el video (trampa #15) | No existe: no hay pista de video que cambie |
| El primer GOP llega roto (trampa #19) | No existe |
| ~30–45 min de batería de los lentes | Es carga de llamada, no de cámara: se estima en horas — sin medir |
| 4.51 MB/min | 0.48 MB/min: una hora entra en 29 MB |
Lo que se paga, y está dicho en la propia pantalla:
- Suena a llamada: 8 kHz mono, o 16 si el enlace negocia banda ancha (mSBC). No se elige: lo acuerdan los lentes y el teléfono, y la app pregunta cuál salió en vez de suponerlo. Además el beamforming aísla tu voz y aplasta el ambiente: perfecto para notas de voz, malo para grabar una sala.
- Mientras graba no hay A2DP: música y bocinas de los lentes suenan a teléfono hasta que pares.
- Una llamada entrante se queda con el canal y corta la grabación. Lo grabado hasta ahí se guarda; la app lo dice en vez de dejar el cronómetro corriendo.
- El LED no se enciende. Para los lentes esto es una llamada, no una captura. Nadie alrededor tiene señal de que estás grabando.
Dos detalles de implementación que valen su comentario:
- El cronómetro sale del audio escrito, no del reloj. Si el micrófono no entrega nada, el número se queda en 00:00 — que es exactamente lo que hay que ver. Junto al medidor de nivel, convierte "graba y a ver qué salió" en "veo que está entrando sonido".
M4aRecorderes un grabador aparte, noMp4Recordercon el video apagado: aquel está construido alrededor de la pista de video y da por vacía cualquier grabación sin imagen.
Es lo que los lentes sí son: un micrófono de solapa con cancelación de ruido. Su firmware combina los cinco micrófonos para perseguir la voz del que los lleva y aplastar el resto — inmejorable para dictado o notas caminando, inservible para ambiente. Dos ajustes salidos de medirlos:
- Ganancia ×2 (+6 dB). Medidos contra el teléfono en la misma escena, los lentes entregan ~7 dB menos (−36.9 dB contra −30.1). Sin corregirlo la grabación se oye tímida. Se aplica al PCM con recorte duro a fondo de escala.
- Bitrate según el ritmo real. 64 kbps para una fuente de 8 kHz es tirar espacio: el AAC no puede inventar lo que el enlace no trajo. Ahora 32 kbps para 8 kHz, 48 para 16 — una hora en ~14 MB en vez de 29.
El techo es HFP y no hay vuelta. Los lentes exponen Handsfree, AudioSink y Avrcp, y ningún perfil LE Audio (verificado en dumpsys bluetooth_manager): no existe camino de 16 kHz o superior para su micrófono en este hardware. El 48000 que el teléfono reporta entre los ritmos disponibles es remuestreo del HAL, no ancho de banda real.
Ajustes → "Prestar el micrófono a otras apps". LePe abre la ruta de audio hacia los lentes y la deja abierta sin grabar nada; a partir de ahí la cámara nativa, WhatsApp o cualquier grabadora pueden capturar con los lentes puestos, como si fueran unos audífonos Bluetooth normales.
La idea sale del mismo comportamiento que arruinó el estéreo (trampa #25): con la ruta de llamada abierta, este teléfono le sirve el micrófono de los lentes a cualquier app que pida "el micrófono". Lo que era un estorbo es aquí el mecanismo.
LePe no captura mientras presta — si abriera su propio AudioRecord se quedaría el micrófono y la otra app recibiría silencio. El préstamo se hace en dos niveles: seleccionar el dispositivo de comunicación (no toca el modo de audio) y, si eso no basta, MODE_IN_COMMUNICATION, que es lo que de verdad arrastra las capturas ajenas. Se va directo al nivel 2 porque el nivel 1 deja la ruta seleccionada pero la cámara sigue oyendo por el teléfono. Sin garantía y sin verificar todavía: cada fabricante decide a dónde manda la captura de otra app, y algunas cámaras se niegan a grabar cuando el teléfono declara una comunicación activa. La forma de comprobarlo es medir el espectro del video resultante: si el sonido está limitado a ~4 kHz, vino de los lentes.
Hay un segundo micrófono de los lentes que nunca habíamos usado. Hasta v0.8.0 el audio de los lentes solo entraba por el perfil de llamada Bluetooth (HFP): 8 kHz mono, con el firmware persiguiendo la voz del portador. Descompilando mwdat-camera-0.9.0 (5-sep-2026, javap sobre el classes.jar del caché de Gradle) apareció otro camino: el stream del SDK puede llevar audio AAC por la misma tubería que el video, y la API pública simplemente no lo enciende.
Lo que hay dentro de com.meta.wearable.dat.camera.internal:
WarpEventCoordinator(lo creaStreamImplal conectar) tieneregisterAudioStreamingEnabled,registerToReceiveEncodedAudioy, en el handshake, mandaConfigureAudioStreamRequest+sendAudioStartRequestsolo siStreamingStateData.audioEnabledes true. La API pública lo deja en false.- Formato por defecto: AAC-LC, 44.100 Hz, 96 kbps, 2 canales. Contra los 8 kHz mono del HFP.
- El pedido lleva un
AudioCaptureProfilecon dos valores,LIVE_CAPTUREyVIDEO_CAPTURE(el SDK manda el segundo, clavado). Es la única perilla que existe sobre el procesado de los micrófonos; qué hace cada una no se sabe hasta medirlo. Cambiarla exigiría reconstruir el proto del handshake: no está hecho. - Los frames llegan por
MetaWearablesDATAudioEventListener.encodedAudioFrameReceived(flag, ByteBuffer, ptsUs)como AAC crudo, que es justo lo que el muxer quiere: no se recodifica nada.
Cómo lo enciende LePe (media/StreamAudioTap.java — en Java a propósito: las clases son públicas en el bytecode pero el compilador de Kotlin las bloquea por su metadata): se arma antes de stream.start(), un hilo vigila el campo privado coordinator de StreamImpl y, en cuanto aparece y antes de que el canal termine de registrarse, pone audioEnabled y encodedAudioForwarding en la StreamingStateData y se registra como oyente. Si llega tarde, el handshake ya pasó sin audio: por eso el estado se lee en Ajustes ("armado a los N ms", "N frames recibidos", o en rojo lo que falló) y al grabar, si no está armado, se vuelve al micrófono de siempre y se dice. media/StreamAudioWriter.kt sintetiza el csd-0 (el SDK tampoco lo manda: su decodificador lo fabrica igual), quita cabeceras ADTS si vinieran, y alinea el reloj: si el PTS del audio cae en el mismo dominio que el del video, resta la misma base (sincronía exacta); si no, ancla el primer frame a la posición actual del video y lo deja escrito en el log.
Con esto activo no se abre HFP: las bocinas no se degradan y el Bluetooth no reparte el caudal con la ruta de llamada. Sí lo reparte con el propio audio del stream (~96 kbps de los ~500 medidos): si el video pierde nitidez, es por eso.
Medido el 5-sep-2026, primer clip (82 s, 720×1280, lentes Gen 2 + S25 Ultra):
armado a los 2292 ms · pedido 44100 Hz x2 @ 96 kbps
AAC #1 2 bytes 12 10 <- el ASC, identico al sintetizado
AAC #2 278 bytes cada ~16 ms <- frames AAC crudos, sin ADTS
Audio y video comparten reloj -> arranca en 41 ms
| Pista | Resultado |
|---|---|
| Audio | AAC-LC 44.100 Hz, 2 canales, 94.9 kbps, 82.5 s |
| Nivel | mean −34.6 dB / max −6.1 dB |
| Energía 20–4k / 4–8k / 8–16k | −34.8 / −48.4 / −57.9 dB — hay contenido por encima de los 4 kHz que HFP nunca deja pasar; el espectrograma llega a ~14 kHz (techo normal del AAC a 96 kbps) |
| Diferencia L−R | −45.8 dB, 11 dB bajo la señal: estéreo real, no un mono duplicado |
| Video | 499.9 kbps a 720×1280 — no bajó por llevar el audio encima |
Lo que ese último dato dice: el enlace llevó ~595 kbps en total, así que los ~500 kbps del video no eran el techo del Bluetooth sino lo que el codificador de los lentes decide entregar.
Con la misma puerta interna (StreamAudioTap, parámetros videoBitrateBps y disableGlassesAbr, hoy sin ficha en la UI) se probaron todas las perillas que el protocolo ofrece sin suplantar a una app de Meta. Cada fila es un clip real medido con ffprobe:
| Qué se cambió | Cómo | Video entregado | Daño |
|---|---|---|---|
| Nada (SDK) | VideoFormat.bitRate=750000 |
500 kbps @720p | — |
| Bitrate 1200 | StreamingStateData.setVideoFormat(copy(bitRate=1200000)) antes del handshake |
499 kbps @720p | — |
| Bitrate 2000 | ídem | 501 kbps @720p | — |
| Escalera reescrita | segundo ConfigureVideoStreamRequest por WarpTransport.sendCoordinationMessage, 68 ms tras el del SDK, con stepUpLadder acabando en 1200000,720,1280,24 |
499 kbps @720p | — |
| ABR de los lentes apagado | reenviar SessionSettings idéntico al del SDK + AbrSettings{enableGlassesAbr=false} |
485 kbps @504p | 71 frames rotos al inicio (los lentes reinician el codificador y el grabador toma ese GOP de calentamiento como bueno) |
Datos que ayudan a entender el porqué: el SDK nunca manda AbrSettings (los lentes usan su default); el ABR del lado del teléfono (FrameRateAdaptiveVideoConfigHandler) existe pero nadie lo instancia en 0.9.0; la respuesta a una configuración extra se acepta (el coordinador completa el ack por tipo, no por nonce). Es decir: los mensajes llegaron y los lentes los ignoraron. El límite está en el firmware, probablemente ligado a ApplicationType=UNKNOWN (las apps de Meta declaran INSTAGRAM/FACEBOOK/MMAI_LIVE); comprobarlo exigiría hacerse pasar por ellas, y eso no se hace.
La única pista que podría cambiar el orden de magnitud. En iOS el SDK tiene transporte Wi‑Fi oficial desde 0.8.0 y un desarrollador reporta 720×1280@30 donde Bluetooth daba 504×896 (issue #233 del repo iOS). En Android el changelog no lo anuncia, pero mwdat-core 0.9.0 lleva la ruta cliente completa, verificada en bytecode con ayuda de Astra (buzon/archivo/2026-09-06-1114-wifi-transporte-respuesta.md):
LinkedDevice.createLinkLease(targetState, attribution)conTargetLinkStateLOW=0 (BLE), MEDIUM=1 (BTC), HIGH=2 (Wi‑Fi Direct). DAT pide hoy (0,1) víaLeaseAuditTracker.trackedCreateLinkLease.- Un lease HIGH se pide a la app Meta AI por Binder (
MwaLinkLeaseClient, serviciocom.meta.wearable.acdc.service.ACDCService.BINDdecom.facebook.stella), que devuelve IP y puerto; el SDK abre un socket TCP, construyeWifiIOLinkyLinkSwitchJob.upgradeFromBtcToWiFiDirectconmuta la mismadatax.Connectiondel stream. No hay que implementar nada del SDK. - Meta AI puede negarse:
SDK_VERSION_NOT_ALLOWED_TO_USE_WIFI,LINKING_APP_PACKAGE_NAME_MISSING,APP_NOT_ALLOWED_TO_USE_WIFI_DIRECT,WIFI_NOT_SUPPORTED_ON_DEVICE. - Trampa: el callback del lease dice
ACTIVEal registrarse localmente, antes de cualquier concesión. El veredicto esLinkState -> HIGH. - Nada en el protocolo de cámara negocia calidad por transporte: Wi‑Fi operativo no garantiza más de 500 kbps. Son dos resultados independientes.
capture/WifiLeaseProbe.java (Java para saltar la visibilidad internal) corre en CaptureEngine.start entre STARTED y addCamera: observa el enlace, pide un lease MEDIUM de respaldo con la atribución de DAT, pide una sola vez el HIGH, espera hasta 30 s, y deja arrancar la cámara sobre lo que haya. Al apagar suelta sus Subscriptions en orden inverso; el SDK hace el descenso. Traza en pantalla, en logcat (LePeWifiProbe, MwaLinkLeaseClient, LinkSwitchJob, ACDC* con elevateLogs) y en captures/wifi-probe-*.log. Identidad real, sin tocar ApplicationType ni emparejamiento.
Resultado (6-sep-2026, S25 Ultra, Meta AI 287.1.0.14.153, Android 16):
ACDCService: assertTrustedBinderIdentity [com.rbmeta.glasscast] ... 2P app is trusted
ACDCService: ACDCApp[name=GlassCast, bundleIdentifier=com.rbmeta.glasscast, requireCTA=true,
allowBTC=true, allowWiFiDirect=true] is allowed to use WiFi Direct
WifiDirectGroupOwnerImpl: Creating group with frequency: 5180 (BW_80MHZ) <- el TELEFONO es el group owner
LinkState -> HIGH · 1053 The device is connected over HIGH after switching from MEDIUM
*** HIGH alcanzado en 3607 ms · connection@249341721 (la misma)
| Sesión | Transporte | Pedido | Video entregado | Errores de decodificación |
|---|---|---|---|---|
| Referencia | Bluetooth | 750 (SDK) | 500 kbps @720p | 0 |
| Sonda 1 | Wi‑Fi Direct | 750 (SDK) | 494 kbps @504p | 129: 71 del primer GOP + ráfaga de 3 s en t=21 |
| Sonda 2 | Wi‑Fi Direct | 2000 + escalera reescrita | 495 kbps @720p | 276: primer GOP + ráfagas en t=15, 30, 35, 41 |
Tres cosas quedan demostradas: (1) LePe está en la lista de apps permitidas de Meta AI para BTC y Wi‑Fi Direct (probablemente por el registro en Developer Mode: requireCTA=true); (2) el teléfono no pierde su Wi‑Fi de internet, porque hace de punto de acceso P2P en 5 GHz; (3) el bitrate no depende del transporte: 500 kbps por Bluetooth y 500 por Wi‑Fi, aunque se pidan 2000. El tope vive en el codificador de los lentes, por política para apps de terceros. El Wi‑Fi además trae pérdidas en ráfagas (lo mismo que el issue #233 de iOS), así que hoy es peor que Bluetooth para grabar. La sonda queda en Ajustes, apagada, por si un firmware o SDK futuro cambia la política; las fichas de bitrate solo aparecen con ella encendida.
Medido el mismo día, con el botón de los lentes y LePe cerrada, importado por Meta AI a Download/Meta AI/:
| Stream a LePe (BT o Wi‑Fi) | Nativo importado por Meta AI | |
|---|---|---|
| Resolución | 720×1280 | 1360×1808 (con el ajuste de calidad de Meta AI en un escalón intermedio; 3K existe) |
| Bitrate de video | ~500 kbps | 8.3 Mbps |
| Códec / color | HEVC 8 bits SDR | HEVC Main 10, HDR HLG (bt2020) |
| Audio | AAC 44.1 kHz estéreo (por el stream) | AAC 48 kHz estéreo |
| Duración | sin límite | tope por clip del firmware (3 min en Gen 2) |
| Errores de decodificación | 0 (BT) | 0 |
No se pueden tener las dos a la vez. Los lentes reportan a Meta AI qué "apps" internas tienen activas (AutocaptureServiceImpl: DeviceAppStateList): con LePe transmitiendo están activas LIVE_STREAMING y CAPTURE_VIDEO; al pulsar el botón o decir "Hey Meta, graba un video", CAPTURE se activa 0,1–3 s y vuelve a inactiva (el tono que suena es el de rechazo), no se guarda ningún archivo (Meta AI: No pending captures), y el stream cae a 504p y a 55–200 kbps durante ~20 s antes de recuperarse. De ahí el diseño que sigue: un modo Continuo (stream, sin límite, 500 kbps) y un modo Máxima calidad por clips (nativo con LePe apagada, importado por Meta AI, cosido después), excluyentes y dichos así al usuario.
Dos detalles de Meta AI vistos en su logcat: BTC auto import not enabled (la importación automática por Bluetooth es un ajuste del usuario, apagado por defecto aquí) y Not safe for BTC brownout: audio is active (no importa mientras el teléfono tenga audio activo, incluido un "solo audio" de LePe).
Enmienda a la trampa de compressVideo: con compressVideo=false el SDK no entrega píxeles crudos del sensor; entrega la decodificación local del mismo stream HEVC (verificado por Astra en StreamImpl.configureCoordinator → registerToReceiveDecodedVideo → VideoDecoder). Recodificarlo a más bitrate no recupera nada.
Lo que sí queda para mejorar la imagen: elegir bien resolución y fps para el caudal fijo (504×896 sigue siendo lo más nítido por píxel), y post-proceso en el teléfono. Hay un prompt para pensar enfoques nuevos en docs/prompt-resolucion-video.md. Pendiente de juzgar con el oído y contra un clip HFP de la misma escena: cuánto ambiente entra frente al beamforming de la llamada.
Riesgos, dichos de frente: es API interna, puede romperse con cualquier versión nueva del SDK, y roza los Wearables Developer Terms (no toca firmware ni protocolo: usa clases que Meta compiló pero no publicó). Que los lentes honren el pedido de una app de terceros es hipótesis hasta la primera prueba.
El modo por defecto graba con ambos micrófonos en un solo archivo estéreo: los lentes al canal izquierdo (tu voz, aislada por el beamforming), el teléfono al derecho (el ambiente). Cada micrófono conserva su pista — se puede silenciar uno después, o comprobar cuál falló — pero el archivo es uno. También se puede elegir un micrófono solo.
El problema de fondo es la sincronía, y se resuelve en tiempo real, no con un script posterior. Dos capas: los micrófonos no arrancan a la vez (el de los lentes tarda ~2 s en abrir su ruta Bluetooth), y aunque arrancaran juntos, cada uno late con su propio reloj de hardware y la diferencia se acumula — típicamente 70–350 ms por hora, un eco evidente al final de una grabación larga.
DualMicMixer lo resuelve haciendo del teléfono el reloj maestro: cada muestra suya produce exactamente un cuadro estéreo, y el canal de los lentes se sirve de una cola regulada por nivel, como un tinaco con flotador — si crece más del tope se recorta al objetivo (40 ms), si se vacía se repite la última muestra. Correcciones de milisegundos, inaudibles en voz, que impiden que el desfase se acumule jamás. Los contadores de muestras tiradas/repetidas salen en el log al detener (adb logcat -s CaptureViewModel), y son el dato para juzgar si hace falta algo más fino. El canal de los lentes (8 o 16 kHz) se re-muestrea a los 44.1 kHz del teléfono por interpolación lineal. El estéreo va a 96 kbps (~43 MB/hora).
La colisión de rutas: verificada dos veces, y en este teléfono el estéreo NO es posible (trampa #25). Con la ruta de llamada (SCO) abierta hacia los lentes, el S25 Ultra sirve el micrófono de los lentes a todas las capturas — y setPreferredDevice(BUILTIN_MIC) no lo cambia: la segunda prueba (27-ago-2026, con esa petición activa) volvió a salir con correlación 0.995 entre canales. Peor aún: routedDevice reportó lo pedido mientras el audio real seguía siendo el de los lentes — la ruta reportada puede mentir. Por eso desde v0.6.2 el veredicto lo da StereoSentinel, que escucha el propio sonido: correlaciona los dos canales cada ~5 s (decimados a 4 kHz, desfases de −230 a +10 ms) y a los dos veredictos consistentes lo dice en pantalla — verde "cada canal sale de su micrófono" o rojo "es la misma voz dos veces" — y lo repite al guardar. El costo de descubrirlo fue una reunión de una hora perdida: ambos canales eran los lentes, cuyo beamforming borra a todo el que no los lleva puestos. De ahí la otra decisión de v0.6.2: el micrófono por defecto del modo solo audio es el del teléfono, que es el único que capta la sala; los lentes y el estéreo quedan como opciones con su advertencia. Si los lentes no aparecen al arrancar, el estéreo degrada solo a mono del teléfono.
La pestaña Archivo lista los videos y los audios guardados, con su fecha, su tamaño y su duración; tocar uno lo abre en el reproductor del teléfono.
Sale de MediaStore, no de la carpeta interna de la app, y eso es una decisión: la carpeta interna tiene los originales de trabajo —trozos cortados por cambio de resolución, archivos a medio cerrar— mientras que MediaStore tiene lo que de verdad quedó guardado y lo que sobrevive a desinstalar la app. Se miran tres carpetas: Películas/LePe, Música/LePe y Películas/GlassCast, la vieja de antes del cambio de nombre.
No añade ningún permiso: una app siempre ve en MediaStore los archivos que ella misma creó.
| Modo | Qué hace | Cuándo |
|---|---|---|
| H.264 transcode (default) | decoder surface → encoder surface, sin copias por CPU | TikTok, Twitch, todo |
| HEVC passthrough | manda el HEVC tal cual por enhanced RTMP | YouTube y RTMP moderno. TikTok casi seguro lo rechaza |
Verificado leyendo el código de RootEncoder 2.8.0: su H265Packet espera exactamente Annex-B con start codes, y setVideoInfo(sps, pps, vps) acepta el VPS. El passthrough es viable sin tocar un byte.
No se puede desde el SDK. No es una limitación que se pueda rodear con más código: la capacidad no existe en la API. Verificado descompilando los tres AAR de com.meta.wearable v0.9.0 y listando toda la superficie pública.
Lo único que el DAT expone son dos capacidades:
| Capacidad | Qué da |
|---|---|
stream |
StreamStart / StreamStop — el video en vivo comprimido |
capture |
CapturePhotoRequest / GetImageRequest — una foto del stream |
No hay ninguna capacidad de biblioteca de medios: nada de listar, leer, importar o borrar lo que los lentes tienen guardado. Tampoco hay forma de disparar su grabación nativa.
La maquinaria de transferencia sí existe dentro del SDK — com.facebook.wearable.airship.api trae AirshipSender, AirshipReceiver, AirshipFileInfo, CachingStrategy — pero es transporte interno, sin API pública, y es lo que usa la app de Meta AI (com.facebook.stella). El logcat lo deja ver: mientras LePe transmitía, la app de Meta reportaba unsatisfiedConditions=[Device Streaming], es decir, no podía importar porque nuestro stream estaba activo. Las dos vías se excluyen.
Conclusión práctica: para material en máxima calidad hay que grabar con el botón de los lentes y dejar que la app de Meta lo importe. LePe juega en el otro terreno — lo que la app oficial no deja hacer: duración sin tope, transmisión, modos de captura y grabación de solo audio. Son caminos complementarios, no sustituibles.
Detalle curioso del protocolo interno: SnapshotSizeProto contempla SNAPSHOT_SIZE_FULL y SnapshotQualityProto contempla SNAPSHOT_QUALITY_HIGH, así que el transporte admite fotos a resolución completa. Pero Stream.capturePhoto() no recibe parámetros, así que no hay manera de pedirlo desde la API pública. Si Meta lo expone algún día, la foto mejora sin tocar nada más.
No son bugs ni cosas por optimizar. Son el techo:
| Límite | Valor | Por qué |
|---|---|---|
| Resolución máxima | 720×1280 | El DAT solo expone HIGH/MEDIUM/LOW |
| FPS | 2, 7, 15, 24, 30 | Valores fijos del SDK |
| Transporte | Bluetooth Classic | No hay Wi-Fi directo |
| Duración real | ~30–45 min | Batería de los lentes + corte térmico |
| Foto | 1440×1080 (1.6 MP) en HEIC, no 12 MP | La API saca la imagen del stream, no dispara la cámara nativa. Medido en un archivo real |
| Mic de los lentes | 8 kHz mono (HFP), 16 si el enlace negocia banda ancha | Suena a llamada; además apaga el A2DP mientras graba |
| Batería de los lentes | no hay porcentaje | Solo eventos BATTERY_LOW / BATTERY_CRITICAL |
Imposible y no vale la pena intentar: desbloquear la grabación 3K de más de 5 min en los lentes, acceder a sus archivos guardados (verificado descompilando el SDK — ver arriba), disparar la grabación nativa por software, correr código en los lentes, o apagar el LED de grabación (no existe en el API; es una decisión de privacidad deliberada).
Cada una es una tarde perdida. Todas están resueltas en el código; si algo se refactoriza, aquí está el porqué.
-
StreamConfigurationtiene un TERCER parámetro:compressVideo, y su default esfalse. Sin ponerlo entruelos lentes envían YUV420 crudo — 1,382,400 bytes por frame a 720×1280 conisCompressed=false. Todo el pipeline lo descarta en silencio: LED encendido, pantalla negra, cero grabado. Ni la documentación ni el sample lo mencionan; solo aparece volcando los bytes crudos al log con hardware real. -
session.start()es asíncrono — hay que esperar aSTARTEDantes deaddCamera(). Si no, falla conSession has already been stopped. El snippet de "getting started" de Meta las llama en líneas consecutivas, pero su propia tabla de ciclo de vida diceSTARTED — ready for capabilities. La tabla tiene razón. Por esoCaptureEngine.start()essuspend. -
DeviceSession.errorses la única vía por la que lleganBATTERY_CRITICAL,THERMAL_CRITICAL,PEAK_POWER_SHUTDOWNySESSION_ENDED_BY_DEVICE. Sin escucharla, una sesión que muere por calor se ve idéntica a una que se detiene sola. -
El orden con HFP es sagrado.
addCamera()→ activar HFP → esperar ~2s →stream.start(). Al revés el audio muere en silencio, sin error. -
videoFrame.isCodecConfigno es de fiar. Hay que parsear los NAL siempre. -
El SDK prefija el VPS con un start code doble (
00000001 00000001 40 01). Leído ingenuamente, el VPS se parsea como tipo 0 y desaparece → MP4 sin pista de video. -
El VPS llega una sola vez, al arrancar el stream. Por eso
HevcParameterSetslos acumula: si grabas 30 s después, sin esto el archivo sale sin video. -
Algunos decoders HEVC por hardware corrompen este stream (
OMX.Exynos.hevc.dec,c2.mtk.hevc.decoder). El código prefiere software, igual que el SDK. -
Los PTS deben ser monótonos. Uno que retrocede hace que
MediaMuxerrechace la muestra conERROR_MALFORMEDy que el ingest RTMP descarte el frame en silencio. -
Altura fija en
dpalrededor de texto enspse corta. Con el tamaño de fuente del sistema por encima del normal el contenido crece pero la caja no. Nunca poner altura fija a un bloque de texto. -
lineHeightmenor quefontSizerecorta los trazos altos. Para números de display:LineHeightStyle(trim = Both)+includeFontPadding = false. -
La trailing lambda en Compose se ata al ÚLTIMO parámetro.
GhostPill("x") { ... }engancha la lambda amodifier, no aonClick. Nombrarla siempre. Mordió tres veces. -
VideoFrame.width/heightmienten cuando el Bluetooth va justo. Reportan lo que se PIDIÓ, no lo que llega. Medido en un dashcam real: se pidió 720×1280 y los lentes mandaron 504×896, con el SDK reportando 720×1280 todo el tiempo. Escribir esa cifra en la cabecera del MP4 produce un archivo que miente sobre su propio contenido y no se reproduce. La única fuente de verdad es el SPS del propio stream (parseHevcSpsDimensions). Ojo con la ventana de conformidad: HEVC codifica en bloques, así que 720×1280 se codifica como 736×1280 y hay que restar el recorte, o la cifra vuelve a estar mal. -
El modo de captura solo existe al abrir el stream. fps y resolución se fijan en
addCamera(StreamConfiguration(...))y no se pueden cambiar en caliente. Cambiar de modo con el stream abierto no cambiaba nada: un dashcam elegido a mitad de sesión seguía corriendo a 24 fps en vez de 15 — y encima salía peor, porque a 24 fps el Bluetooth va tan justo que el SDK acaba bajando la resolución (ver trampa #13). AhorasetModereabre el stream. -
La resolución puede cambiar A MITAD de grabación, y eso congela el video. El SDK trae ABR interno (
internal/abr/AbrLadderGenerator,FrameRateAdaptiveVideoConfigHandler): baja la resolución solo cuando el Bluetooth aprieta, sin avisar y sin cambiar lo que reporta. Una pista de MP4 declara sus dimensiones una vez yMediaMuxerno las puede cambiar, así que a partir de ese punto el archivo describe algo que ya no contiene y el reproductor se queda con la última imagen buena. Medido en un clip real: cambio de 720×1280 a 504×896 en el segundo 5.24, y 32 de 195 frames dejaron de decodificarse. Tomar una foto durante la grabación lo dispara — la foto viaja por el mismo Bluetooth. Solución:Mp4RecorderdevuelveRESOLUTION_CHANGEDyCaptureViewModel.rollSegmentcorta limpio y sigue en otro archivo. -
capturePhoto()devuelve HEIC, no JPEG. Guardarlo como.jpgconimage/jpegproduce un archivo que miente sobre su formato; la galería de Samsung lo abre igual, pero cualquier app que confíe en la extensión se atraganta. Ahora se detecta la cajaftypy se etiqueta comoimage/heif. De paso: la foto real es 1440×1080 (1.6 MP), no ~1 MP. -
El PTS del audio también tiene que ser monótono, y cortar un archivo lo rompe. La trampa #9 se aplicó solo al video. Al cortar por cambio de resolución, el encoder AAC vacía su cola al cerrarse: si se cierra después de abrir el archivo nuevo, ese vaciado —con tiempos de hace veinte segundos— cae en el archivo nuevo, cuyo reloj acaba de empezar en cero. El tiempo retrocede,
MediaMuxerlanzaERROR_MALFORMEDy da por fallida la grabación entera. Costó una grabación completa en hardware. Dos defensas: cerrar el encoder viejo mientras el archivo viejo sigue abierto, y descartar toda muestra de audio cuyo tiempo retroceda en vez de dejar que tumbe el archivo. -
Un fallo del muxer dejaba la app fingiendo que grababa.
muxerFailedse ponía en true en el hilo de frames y nadie más se enteraba: el cronómetro seguía corriendo sobre un archivo muerto y uno lo descubría al final, sin nada que rescatar. Ahora es unStateFlowque cierra la sesión y lo dice. -
El PRIMER GOP del stream no se puede decodificar — el video abre trabado. Al arrancar, los lentes emiten un keyframe cuyas imágenes siguientes todavía se apoyan en material del calentamiento de su codificador, que nadie tiene. Medido: 71 de 543 frames (los primeros 3 segundos) no decodifican; cortando el archivo en el segundo keyframe, cero errores. No se veía antes porque se pulsaba "grabar" con el stream ya estabilizado; con el botón único la grabación empieza a la vez que el stream y ese GOP cae dentro. Solución:
Mp4Recorder.start(skipFirstKeyFrame = true)cuandoCaptureEngine.keyFramesSeen == 0, que tira el GOP entero (no solo su keyframe) y abre en el siguiente. Cuesta ~3 s. El buffer del dashcam hace lo mismo. -
MAX_FRAMES_BEFORE_STARTera más corto que un GOP. El atajo que abre la pista si no llega keyframe estaba en 60 frames, pero los lentes mandan uno cada 72 (3 s a 24 fps): saltaba siempre antes de tiempo y abría el archivo a media escena — el mismo arranque trabado, por otra puerta. Subido a 150. -
El SDK no expone micrófono, y conviene saberlo antes de buscarlo. La interfaz
Streamdel DAT tiene exactamentegetVideoStream(),capturePhoto(),start(),stop(),getState()ygetErrorStream(); elenum Permissiondel SDK tiene una sola entrada,CAMERA. Cualquier audio de los lentes llega por Bluetooth HFP, que es Android puro y no pasa por Meta. Verificado descompilandomwdat-coreymwdat-camerav0.9.0. -
setCommunicationDevice()sinMODE_IN_COMMUNICATIONda silencio, no error. Seleccionar el dispositivo SCO no basta: en bastantes teléfonos el enlace no se abre de verdad hasta que el modo de audio declara que hay una comunicación en curso. La ruta queda "seleccionada",AudioRecordarranca sin quejarse y lee silencio — el mismo síntoma que la trampa #4, por otra puerta, y el mejor sospechoso de que el micrófono de los lentes nunca se haya oído. AhoraMicSource.routeToGlasses()pone el modo antes de seleccionar, yclearRoute()lo devuelve (si se queda enMODE_IN_COMMUNICATION, los lentes no vuelven a A2DP). -
El ritmo del micrófono HFP no se elige: se negocia. 8 kHz (CVSD) o 16 (mSBC, banda ancha) lo acuerdan los lentes y el teléfono. Declarar 16 sobre un enlace de 8 no suena mejor: engorda el archivo con muestras remuestreadas, y si el
AacEncodercree un ritmo distinto del real el audio se desincroniza poco a poco del video, porque su PTS se deriva de los bytes consumidos. Ahora se le pregunta al dispositivo (AudioDeviceInfo.sampleRates) y el encoder usamic.sampleRate. -
Grabar audio en segundo plano exige el tipo
microphoneen el foreground service. Con soloconnectedDevice, Android revoca el micrófono en cuanto la app deja de estar visible — y una grabadora de audio se usa precisamente con el teléfono en el bolsillo. El tipo se añade solo siRECORD_AUDIOestá concedido: declararlo sin el permiso tumba el servicio conSecurityException, y eso se llevaría por delante también la grabación de video. -
Con SCO abierto, "el micrófono" puede ser el de los lentes aunque pidas el del teléfono — y NADA de la API lo confiesa. Medido dos veces en el S25 Ultra (26 y 27-ago-2026): la captura
MICa 44.1 kHz recibía el micrófono HFP de los lentes remuestreado (correlación 0.995 entre "canales"), consetPreferredDevice(BUILTIN_MIC)activo, yroutedDevicereportando lo pedido como si nada. Ni pedir el dispositivo ni preguntar por la ruta sirve: la única verdad es el sonido mismo, y por eso existeStereoSentinel. Costó una reunión de una hora — el beamforming de los lentes borró a todos los demás participantes. -
El foreground service no basta contra Samsung: hace falta la exención del ahorro de batería. El servicio de tipo
microphone+ wake lock mantiene la grabación viva ante Android "de libro", pero Samsung duerme apps por su cuenta con la pantalla bloqueada, servicio incluido. La exención (REQUEST_IGNORE_BATTERY_OPTIMIZATIONS+ diálogo del sistema) es lo que de verdad deja a LePe despierta; la app la pide desde la pantalla de audio y desde Ajustes → "Con la pantalla bloqueada", y comprueba su estado en cadaonResumeporque el usuario puede revocarla en Ajustes del sistema. Se verifica conadb shell dumpsys deviceidle whitelist.
settings.gradle.kts: los escapes deincludeGroupByRegex("com\\.android.*")se rompen con facilidad y el script ni siquiera parsea. Se quitaron los filtros.- La API 37 se instala como
android-37.0y AGP buscaandroid-37. Ver el aviso de la sección "Ya instalado en esta máquina": hay que duplicar el directorio y corregirAndroidVersion.ApiLevelen la copia. - RootEncoder 2.8.0 exige
compileSdk 37, y AGP 8.11.1 (el del sample de Meta) tope en 36. Por eso el proyecto usa AGP 8.13.2 / compileSdk 37. - El SDK de Anthropic (si se vuelve a añadir para la parte de IA) arrastra Apache HttpComponents y tres jars traen
META-INF/DEPENDENCIES. Hay que excluirlo enpackaging { resources { excludes += ... } }.
app/src/main/java/com/rbmeta/glasscast/
├── GlassCastApp.kt Wearables.initialize() — nada del SDK funciona antes
├── MainActivity.kt Permisos, deep link de registro, wiring de ViewModels
│
├── capture/
│ ├── CaptureEngine.kt ★ DeviceSession → Camera → Stream + reparto de frames
│ ├── CaptureMode.kt Los 4 modos y su reparto del caudal Bluetooth
│ ├── Runway.kt ★ Cuántos minutos quedan y QUÉ lo limita
│ ├── LinkBudget.kt ★ Datos por píxel: por qué se ve borroso, en un número
│ ├── HardwareAlerts.kt Errores del SDK → mensajes accionables en español
│ ├── DeviceVitals.kt Batería y almacenamiento del teléfono
│ ├── CaptureViewModel.kt Orquesta preview + grabación + dashcam + RTMP
│ └── CaptureUiState.kt Estado y ajustes
│
├── media/ ★ El núcleo
│ ├── Hevc.kt Parsing Annex-B, keyframes, VPS/SPS/PPS
│ ├── FrameSink.kt Un frame → N consumidores
│ ├── FrameRingBuffer.kt Buffer circular del dashcam (recorte por GOP)
│ ├── HevcSurfaceDecoder.kt HEVC → Surface (preview Y transcoder)
│ ├── HevcToH264Transcoder.kt decoder surface → encoder surface, sin tocar CPU
│ ├── OutputFormat.kt 9:16 / 4:5 / 1:1 / 16:9 y su matemática de recorte
│ ├── FrameCropRenderer.kt ⚠ El recorte en GPU (EGL + SurfaceTexture + shader)
│ ├── FormatExporter.kt ⚠ Revela la variante recortada del MP4 ya cerrado
│ ├── RtmpBroadcaster.kt Passthrough HEVC o transcode H.264
│ ├── Mp4Recorder.kt Remux HEVC → MP4, con reescritura de tiempos
│ ├── M4aRecorder.kt ⚠ Solo audio: PCM → AAC → .m4a, una sola pista
│ ├── AacEncoder.kt PCM → AAC, compartido por grabador y RTMP
│ ├── MicSource.kt ⚠ Mic del teléfono (44.1k) o de los lentes (HFP 8/16k)
│ ├── GalleryExporter.kt MediaStore → Movies/, Music/ y Pictures/LePe
│ ├── RecordingsLibrary.kt Lee de MediaStore lo ya grabado (pestaña Archivo)
│ └── GlassesSpeaker.kt TTS por A2DP (NO pasa por el SDK de Meta)
│
├── service/CaptureService.kt Foreground service (connectedDevice + microphone)
├── mock/MockDeviceViewModel.kt MockDeviceKit
└── ui/
├── GlassCastScreen.kt Las 5 pestañas
├── components/ HeroBlock, CostRow, LevelMeter, MediaRow, BottomNav…
└── theme/Theme.kt Sistema visual completo
El nombre de los archivos sigue diciendo GlassCast porque el paquete no se renombró (ver arriba). ⚠ marca lo que nunca se ha ejecutado contra hardware.
Grabar y transmitir a la vez funciona: son dos FrameSink sobre el mismo CaptureEngine, y cada frame se copia una sola vez.
MockDeviceKit reemplaza registro, conectividad y cámara. En la app: Inicio → Sin hardware → Activar. Su estado vive en memoria y se pierde al reinstalar — si aparece No wearable devices have been discovered, es eso.
El video de prueba debe ser h.264 o h.265 real (Android no transcodifica ahí):
ffmpeg -i entrada.mp4 -c:v libx265 -tag:v hvc1 -vf "scale=720:1280" -an prueba.mp4Un solo botón hace todo el camino. Antes eran tres pulsaciones encadenadas — permitir cámara, encender lentes, empezar a grabar — siempre en el mismo orden y siempre obligatorias. Un paso que siempre sigue al anterior no es una decisión, es trámite: la app sabe en qué punto está y lo recorre sola. Al detener apaga todo, incluido el LED de los lentes, y guarda en la galería sin que haya que pedirlo. Apagar sin grabar existe, pero de secundario en Inicio: es el caso raro.
Cada pantalla abre con EL número del que trata, y debajo nombra qué lo está limitando.
| Pantalla | Su número |
|---|---|
| Inicio | minutos que puedes grabar |
| Grabar | cuánto llevas y qué te queda a este ritmo |
| En vivo | cuánto aguanta la transmisión |
| Archivo | cuántas grabaciones tienes y cuánto ocupan |
| Ajustes | cómo cada opción mueve ese número |
En solo audio la regla se dobla sin romperse: el número de arriba sigue siendo el tiempo, pero el dato que de verdad importa es el nivel del micrófono, porque sin imagen es lo único que dice si está entrando sonido. Por eso el medidor va primero y el cronómetro avanza con el audio escrito y no con el reloj.
Es la única pregunta que uno se hace antes de usar unos lentes: cuánto me queda, y por qué. Casi nunca es la batería — suele ser el calor.
De ahí salen las demás reglas: cada opción lleva su precio en minutos a la derecha, en una baldosa de tamaño fijo, para poder comparar sin leer.
Amarillo señal #FFE81A sobre negro #0B0B0B. Tarjetas oscuras de 20dp, bloque protagonista de 30dp, pastillas completas para las acciones.
Tres reglas que lo sostienen:
- El amarillo es dato o acción, nunca decoración. Si algo es amarillo, o es el número, o es el botón, o está seleccionado.
- El rojo está reservado para lo que se acaba. Aparece poco, por eso alarma.
- Un solo relleno de color por pantalla: la acción principal.
Tipografías: Space Grotesk + Space Mono (cifras tabulares), empaquetadas en res/font/ desde el repositorio oficial de Google Fonts (licencia OFL).
El bloque amarillo queda fijo al hacer scroll y colapsa: de 84sp a 40sp, y la explicación se muda a la derecha del número. La respuesta nunca desaparece, solo deja de gritar.
https://claude.ai/code/artifact/cf66bd84-8b46-4301-ac1a-97fc19f27ab5
Sirve como registro de por qué el diseño es así — la argumentación de cada dirección y sus contras siguen valiendo. Pero el código es la verdad, no el lienzo.
En orden de valor:
-
Probar la sonda Wi‑Fi (v0.11.0)Hecho el 6-sep-2026: concedido, funciona, y no sube el bitrate. Ver la sección. No hay más que sacar del stream en 0.9.0. -
Probar el audio por el stream (v0.9.0)Hecho el 5-sep-2026, funciona (ver la sección). Queda comparar con el oído contra un clip HFP de la misma escena. Receta original: Ajustes → "Audio por el stream" → ON, encender y grabar ~20 s hablando con ruido de fondo (una calle, música baja). Antes de grabar, la fila de Ajustes debe decir "armado a los N ms"; mientras se graba, "N frames recibidos". El veredicto está en el archivo, no en el reproductor:ffprobe -show_streams(o el script de átomos) — 44100 Hz y 2 canales significan que vino del stream; 8000 Hz mono, que cayó al micrófono. Después, dos preguntas que solo el sonido responde: ¿entra el ambiente o sigue siendo solo la voz? (compararlo con un clip de HFP de la misma escena,mean_volumey espectro) y ¿cuánto bajó el caudal del video? (bytes/píxel conLinkBudgeten pantalla). En el log,adb logcat -s StreamAudioTap StreamAudioWriter: los tres primeros frames con su cabecera y si los relojes coincidieron. -
Probar el préstamo del micrófono (v0.8.0). Ajustes → "Prestar el micrófono" → abrir la cámara nativa → grabar 15 s hablando con los lentes puestos y el teléfono lejos de la boca → volver a LePe y dejar de prestar. El veredicto se mide, no se opina:
ffmpeg -i video.mp4 -af "showspectrumpic=s=640x480" espectro.png, o más simple, comparar elmean_volumecon el teléfono tapado. Si el sonido está limitado a ~4 kHz, vino de los lentes; si llega a 15 kHz, la cámara siguió oyendo por el teléfono y el préstamo no funciona en este hardware.
0b. Conceder la exención de batería y probar con pantalla bloqueada (v0.7.0). Al abrir Grabar → Solo audio aparece el botón "Permitir grabar con pantalla bloqueada"; concederla, y después grabar ~10 minutos con la pantalla bloqueada y el teléfono en el bolsillo. El cronómetro al volver dice la verdad: si avanzó todo el tiempo, sobrevive; si se quedó corto, Samsung la durmió igual y hay que mirar Ajustes → Batería → límites en segundo plano. Verificable también con adb shell dumpsys deviceidle whitelist | grep glasscast.
- Comprobar el centinela (v0.6.2). El estéreo ya se sabe imposible en este teléfono; lo que falta verificar es que la app lo DIGA sola: grabar en "Los dos, en estéreo" ~15 segundos hablando, y confirmar que aparece el aviso rojo ("los dos canales salen del micrófono de los lentes") y que al guardar lo repite. Es la red de seguridad para cualquier otro teléfono donde se instale la app.
1b. Solo audio con el teléfono guardado. Grabar unos minutos con la pantalla apagada y el teléfono en el bolsillo, que es como se usa de verdad. Ahí se comprueba el tipo microphone del foreground service (trampa #24). De paso, provocar una llamada entrante y ver que la app cierra el archivo y lo dice, en vez de quedarse con el cronómetro corriendo.
-
Probar las formas de salida. No necesita servidor: grabar un clip corto con formato 1:1 elegido, detener, esperar el "revelando", y abrir la variante. Verifica de una vez todo el camino GL (decoder → recortador → encoder), que también es el corazón del recorte en vivo. Ojo con la orientación: si la variante sale volteada o negra, el sospechoso es el shader/matriz de
FrameCropRenderer. -
Volver a probar el dashcam. Los tres bugs de la primera prueba están arreglados pero sin verificar: que el clip abra, que tenga audio desde el momento en que pulsaste, y que la costura entre pasado y presente no tenga saltos. El buffer circular es lo más delicado que hay: si el recorte por GOP se equivoca, el MP4 sale indecodificable. El timelapse va en la misma sesión.
-
Probar el RTMP contra un servidor local. Es lo único grande sin verificar. Montar MediaMTX en el PC, transmitir a
rtmp://<ip-local>/live/test, comprobar que el video llega. Solo después ir a YouTube o TikTok. -
Averiguar si el térmico reporta. Una sesión larga con
adb logcat | grep GlassesViewModel. Si siempre esUNKNOWN, quitar ese techo del cálculo y decirlo en la UI. -
Confirmar que las bocinas suenan. Ajustes → Hablar, con los lentes puestos.
-
Medir de verdad la autonomía en solo audio. Hoy el techo son 150 minutos puestos a mano, marcados como estimación en el código. Una grabación larga hasta que los lentes se apaguen lo sustituye por un número real.
-
Publicación. El DAT sigue en developer preview y publicar está restringido a socios. Cuando Meta abra (dicen "más adelante en 2026"), poner
APPLICATION_IDyCLIENT_TOKENreales enapp/build.gradle.kts— hoy están en"0", que solo vale en Developer Mode.
- IA conectada a los lentes. El bucle está completo (cámara vía SDK + bocinas vía A2DP); solo falta el cerebro. Se llegó a escribir
VisionAssistant.ktcon el SDK de Anthropic y compilaba, pero se retiró para no cargar el APK con algo sin usar. Costo estimado: ~$0.01 por consulta con Opus 5, ~$0.002 con Haiku 4.5. - Multi-destino (varias plataformas a la vez), escáner de códigos, fondo difuminado para el 16:9 (en vez de franjas negras, el propio video borroso rellenando — lo que hacen los noticieros; necesita un shader de blur sobre el
FrameCropRendererque ya existe).
Hevc.kt, HevcSurfaceDecoder.kt y Mp4Recorder.kt están adaptados del sample CameraAccess de Meta (Apache 2.0), en facebook/meta-wearables-dat-android. La lógica de parsing replica a propósito la del decoder interno del SDK: desviarse deja la preview en negro cuando el stream trae un CRA en vez de un IDR.
RTMP vía RootEncoder 2.8.0 (Apache 2.0). Tipografías Space Grotesk y Space Mono (OFL).
Aplican los Wearables Developer Terms y la Acceptable Use Policy.