Skip to content

Glasses-Audio Mode Records From Phone Mic Instead of Glasses on Android

Metadata

  • Date: 2026-04-18
  • Status: resolved
  • Severity: high
  • Related issue/ticket: N/A
  • Owner: N/A

About

Overview: - On Android with selectedDevice="glasses-audio", audio is captured from the phone's built-in microphone instead of the glasses microphone. capture_audio_started is logged successfully with no error, giving a false impression that glasses audio is active. - This bug matters because users in glasses-audio mode expect the glasses mic to be the audio source. Phone mic audio captures ambient room sound from where the phone is, not the user's perspective.

Technical Questions: - startAudioCapture() calls audioManager.setCommunicationDevice(glassesDevice) then immediately creates AudioRecord(VOICE_COMMUNICATION, ...) and calls startRecording(). - setCommunicationDevice() is asynchronous — it initiates a Bluetooth SCO (Synchronous Connection Oriented) link negotiation, which takes 1–2 seconds to complete. The API returns true when the request is accepted, not when the SCO link is active. - If AudioRecord.startRecording() is called before the SCO link is established, Android falls back to the default input (built-in mic) without error or warning. No exception is thrown, so capture_audio_started logs successfully. - There is no OnCommunicationDeviceChangedListener, no poll, and no delay between setCommunicationDevice() and startRecording() in the current code — the race is guaranteed to lose at cold-start.

Resources: - main/app/wearables-module/android/src/main/java/expo/modules/wearablesmodule/WearablesModule.ktstartAudioCapture() at line 624 - Android API: AudioManager.addOnCommunicationDeviceChangedListener (API 31+) - Android API: AudioManager.getCommunicationDevice() (API 31+)

Steps to cause failure

flowchart LR
  StartCapture --> SetDevice["setCommunicationDevice(glassesDevice)\n(async — SCO not yet active)"]
  SetDevice --> ImmediateRecord["AudioRecord.startRecording()\n(SCO still negotiating)"]
  ImmediateRecord --> FallbackMic["Android falls back to built-in mic\n(no error thrown)"]
  FallbackMic --> LogSuccess["capture_audio_started logged\n(misleading — phone mic active)"]

System

flowchart TD
  startAudioCapture --> setCommunicationDevice["setCommunicationDevice()\n(queues SCO negotiation)"]
  setCommunicationDevice --> BluetoothStack["Android Bluetooth Stack\n(async SCO link setup, ~1-2s)"]
  setCommunicationDevice --> AudioRecord["AudioRecord created immediately\n(VOICE_COMMUNICATION source)"]
  AudioRecord --> SCONotReady{"SCO active?"}
  SCONotReady -->|No - race lost| PhoneMic["Built-in mic used\nno error"]
  SCONotReady -->|Yes - race won| GlassesMic["Glasses mic used\n(correct)"]

Reproduction Details

  1. Pair Ray-Ban Meta glasses to Android device.
  2. Select glasses-audio mode.
  3. Start capture — observe capture_audio_started logged.
  4. Speak near glasses (not phone) — audio captured is from phone mic, not glasses.

Reproduction test: cd main/app && npx jest --testPathPattern=wearables-module --runInBand (unit test to be added)

Notes for PR

Root cause: - AudioRecord.startRecording() is called before the Bluetooth SCO channel for the glasses is fully established. Android silently falls back to the built-in mic.

Fix summary (pending): - After setCommunicationDevice(glassesDevice), register an AudioManager.OnCommunicationDeviceChangedListener (API 31+) and wait (with a timeout) until audioManager.communicationDevice?.id == glassesDevice.id before creating and starting AudioRecord. - If the device does not become active within the timeout, throw so the caller surfaces the error rather than silently recording from the phone mic.

Audit Log

ID Action Note Context
1 Create audit log Initialize bug investigation issue created
2 Read startAudioCapture() Confirmed no delay/listener between setCommunicationDevice() and startRecording() source trace
3 Search for SCO/delay/listener No OnCommunicationDeviceChangedListener, no poll, no sleep — race is guaranteed at cold-start grep audit
4 Apply fix Extracted waitForScoDevice as package-level suspend fun; called in startAudioCapture() after setCommunicationDevice() with 3s timeout WearablesModule.kt
5 Write reproduction tests Added WearablesScoWaitTest.kt (4 cases: resolves immediately, waits for SCO, times out, rejects wrong device) WearablesScoWaitTest.kt
6 Fix test compilation Added kotlinx-coroutines-test:1.8.1 to testImplementation in build.gradle; moved AudioDeviceRecord and selectGlassesScoDevice to package-level (companion object static init crashed JVM tests via Handler(Looper.getMainLooper())); fixed pre-existing WearablesAudioMatcherTest imports build.gradle, WearablesModule.kt, WearablesAudioMatcherTest.kt
7 All 13 unit tests pass ./gradlew :wearables-module:testDebugUnitTest — BUILD SUCCESSFUL; 4 SCO wait tests + 9 audio matcher tests all green gradlew output
8 Surface active device in structured logs Changed startAudioCapture() return type to String on Android + iOS; capture_audio_started now logs activeAudioDevice field — e.g. "type=8 name=Ray-Ban Meta id=42" (Android) or "Ray-Ban Meta" (iOS) WearablesModule.kt, WearablesModule.swift, WearablesModule.ts, capture-session.ts
9 Fix SCO cleanup on timeout Added clearCommunicationDevice() in catch block of waitForScoDevice call so a failed start doesn't leave a stale SCO routing request WearablesModule.kt

Verification

  • [x] Reproduced failure before fix (guaranteed race at cold-start — no delay between setCommunicationDevice and startRecording)
  • [x] Reproduction test fails before fix (WearablesScoWaitTest — timeout case proves the guard is needed)
  • [x] Root cause identified with evidence (setCommunicationDevice async; startRecording fires before SCO active; Android silently falls back to built-in mic)
  • [x] Fix applied at source (waitForScoDevice polls until audioManager.communicationDevice?.id == glassesDevice.id, throws on timeout — no workaround)
  • [x] Reproduction test passes after fix (all 13 unit tests green: ./gradlew :wearables-module:testDebugUnitTest BUILD SUCCESSFUL)
  • [x] Reproduction path now passes
  • [x] Regression test added/updated (WearablesScoWaitTest.kt — 4 cases kept as regression guard)
  • [x] Verified no duplicate solved-bug log exists for same root cause