publish: Device's default fallback picks "Headset earpiece" on Android, and an app cannot opt out of the deviceId pin
> **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 条评论