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.kt — startAudioCapture() 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
- Pair Ray-Ban Meta glasses to Android device.
- Select
glasses-audiomode. - Start capture — observe
capture_audio_startedlogged. - 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
setCommunicationDeviceandstartRecording) - [x] Reproduction test fails before fix (
WearablesScoWaitTest— timeout case proves the guard is needed) - [x] Root cause identified with evidence (
setCommunicationDeviceasync;startRecordingfires before SCO active; Android silently falls back to built-in mic) - [x] Fix applied at source (
waitForScoDevicepolls untilaudioManager.communicationDevice?.id == glassesDevice.id, throws on timeout — no workaround) - [x] Reproduction test passes after fix (all 13 unit tests green:
./gradlew :wearables-module:testDebugUnitTestBUILD 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