ITADN

publish: Device's default fallback picks "Headset earpiece" on Android, and an app cannot opt out of the deviceId pin

#2554Openfperex 创建于 29 天前
F
fperexcommented
> **Updated 2026-07-29** with on-device confirmation. The original body inferred the Android mechanism from Chromium source and said so; it is now measured, and one detail (device id format) was wrong and is corrected below. History in the comments. **Summary:** `Device`'s default-device fallback ends in `devices.at(0)`, and on Android `enumerateDevices()` returns Chromium's *communication devices* rather than microphones — where index 0 is **"Headset earpiece"**. `Source.Microphone` then pins it with `deviceId: { exact }`. Selecting that device routes the whole call's audio to the earpiece. An application cannot opt out, because the constraint is always sent and `preferred` is self-assigned after the first capture. ### Environment | | | |---|---| | `@moq/publish` | 0.4.1 | | Handset | Poco X7 Pro, Android 15, Brave (Chrome 150) | | App | guest-only meeting app: camera + mic publish, WebAudio playback, managed `cdn.moq.dev` | | Symptom | remote audio plays on the **earpiece** | | Not seen on | iPhone, desktop | ### What Android actually enumerates `enumerateDevices()` on that handset, mic permission granted: ```json [ {"deviceId":"default", "label":"Default", "groupId":"a6c0aec8…"}, {"deviceId":"a38808f8b6…", "label":"Headset earpiece", "groupId":"187ba5af…"}, {"deviceId":"135f4a3918…", "label":"Speakerphone", "groupId":"b09d1d01…"} ] ``` These are not microphones. They are Chromium's synthetic communication devices (`GetCommunicationDeviceNames`), i.e. output routes. The ids are hashed per-origin by Blink, so the **labels** are what identify them. ### Why the fallback lands on the earpiece Walking `Device.#run` against exactly that list: 1. `alias` = the `"default"` row; it is then filtered out, leaving `[Headset earpiece, Speakerphone]`. 2. `devices.find((d) => d.groupId === alias.groupId)` — **no match.** Each synthetic row carries its own unrelated groupId; none equals the alias' `a6c0aec8…`. 3. The audio label scan for `"default"` / `"communications"` — **no match.** The labels are "Headset earpiece" and "Speakerphone". 4. ```ts if (!defaultDevice) { // Still nothing, then use the top one. defaultDevice = devices.at(0); } ``` → **"Headset earpiece"**. Deterministic, not a race. Enumeration order is doing all the work: had the platform listed Speakerphone first this would silently have been fine, which is presumably why it has gone unnoticed. `Microphone.#run` then pins it: ```ts const finalConstraints: MediaTrackConstraints = { ...constraints, deviceId: device !== undefined ? { exact: device } : undefined, }; ``` and per `AudioManagerAndroid::MakeLowLatencyInputStream`, selecting a communication device "switches the device used for **all input and output streams** with communication usage set". Choosing the earpiece as the *input* device is what puts the call's *output* on the earpiece. ### An application cannot opt out There is a window where `requested` is undefined and the constraint is absent — before `enumerateDevices()` resolves. It closes itself, permanently, at the end of the same function: ```ts if (device === undefined) { // Save the device that the user selected during the dialog prompt. this.device.preferred.set(settings?.deviceId); } ``` After one capture `preferred` holds a concrete id the application never chose, and every later capture is pinned to it. `Device.requestPermission()` does the same from the permission prompt. The comment says "the device that the user selected during the dialog prompt", but on a browser whose prompt has no device chooser this is just whatever the platform handed back. So "always follow the system default" is not expressible: not at construction, and not after the first capture. ### Measured | platform | before | after removing the pin | |---|---|---| | **Android** (Brave, Poco X7 Pro) | **earpiece** | **loudspeakers** | | iPhone | loudspeaker | unchanged | | desktop | loudspeaker | unchanged | After: `mic-track {"deviceId":"default","echoCancellation":true,"noiseSuppression":true,"autoGainControl":true,"sampleRate":48000,"channelCount":1}` — settled on `Default`, echo cancellation still requested, audio on the loudspeakers. Two negative results worth recording, both of which cost us time: - Moving playback off `AudioContext.destination` and through an `<audio>` element changed **nothing**. `MakeLowLatencyOutputStream` stamps `AAUDIO_USAGE_VOICE_COMMUNICATION` on every low-latency output stream while communication mode is on, and WebAudio and media elements share that factory — there is no playback-side workaround. - A live A/B of `echoCancellation` on/off did **not** change routing once the pin was gone. So on this handset the pinned deviceId is the entire cause; the communication-mode machinery explains why nothing downstream can compensate, but is not needed to explain the earpiece. ### Repro Join from an Android handset with microphone permission granted and no device explicitly chosen. Remote audio plays on the earpiece. `getSettings().deviceId` on the live publish track shows the pinned id, and `enumerateDevices()` shows the routing table above. ### Same behaviour, second surface Corroboration rather than a second request: `facingMode` in `Source.Camera`'s constraints is inert, because the resolved `deviceId: { exact: … }` is applied last and always resolves. Applications wanting a front/rear switch have to reimplement facing resolution as a deviceId lookup. --- Evidence only — no patch proposed, no PR offered. The remedy is yours to pick; we have worked around it locally by opening the microphone ourselves and omitting the constraint when the user has chosen nothing.
3 条评论