When working on the audio and video calling feature for desktop IM, we encountered this bizarre feedback:
"Calling with the laptop's built-in microphone works perfectly fine, but after putting on AirPods, the other party cannot hear my voice."
This was not an occasional silence; it happened every single time. What was even stranger: local preview had waveforms, device enumeration could find the AirPods, and getUserMedia did not report any errors —— everything looked completely normal, yet the stream pushed out was silent.
After investigating for a whole morning, we finally located the cause: the browser's software noise suppression and the Bluetooth headset's hardware noise cancellation were "clashing".
1. First, Understand What the Audio Link Looks Like
To initiate a WebRTC audio stream on the desktop, the signal goes through the following path:
\text{Physical Microphone} \longrightarrow \text{Driver Layer} \longrightarrow \text{OS Audio Subsystem} \longrightarrow \text{Browser AudioContext} \longrightarrow \text{WebRTC Encoding} \longrightarrow \text{Network Transmission}
Within the browser layer, WebRTC provides several configurable DSP (Digital Signal Processing) switches:
| Parameter | Function |
|---|---|
echoCancellation |
Acoustic Echo Cancellation (AEC): Suppresses echoes of one's own voice played by speakers and re-recorded by the microphone |
noiseSuppression |
Acoustic Noise Suppression (ANS): Filters out background noise |
autoGainControl |
Automatic Gain Control (AGC): Levelizes the volume to a standardized level |
These parameters are configured in the constraints object of getUserMedia:
javascript
const constraints = {
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
For built-in microphones, enabling these three options is almost standard practice —— built-in microphones have low signal-to-noise ratios and are close to speakers, making these auxiliary algorithms necessary. However, the problem lies in the fact that: this logic does not apply to Bluetooth headsets.
2. AirPods Are Not "Ordinary Microphones"
2.1 Bluetooth Headsets Have Built-in Hardware DSP
Modern Bluetooth TWS headsets, represented by AirPods, integrate complete audio processing chips:
- ENC (Environmental Noise Cancellation): Uses dual-mic arrays for beamforming to filter out background noise at the hardware level.
- AEC (Acoustic Echo Cancellation): The headset processes input and output channels simultaneously to achieve echo cancellation inside the chip.
- AGC (Automatic Gain Control): Dynamically adjusts input gain at the firmware level.
In other words, noise cancellation is already completed inside the headset hardware.
2.2 Double Noise Cancellation = Mute
When you enable software noise cancellation in your code and the user wears AirPods, the audio link becomes:
\text{AirPods Hardware AEC/ANS} \longrightarrow \text{Browser Software AEC/ANS} \longrightarrow \text{WebRTC Encoding}
Two layers of noise cancellation algorithms process the exact same audio signal without any coordination mechanism. The result is:
- Hardware noise cancellation has already compressed the "non-speech" part very low.
- The browser's software noise suppression sees a raw signal that is "nearly silent", misinterprets it as noise, and subsequently suppresses it further as noise.
- The final output is close to mute.
[!WARNING]
This is like two people adjusting the same volume knob at the same time —— one turning it up and the other turning it down, resulting in the sound disappearing completely.
2.3 Why Does This Problem Not Exist on Mobile?
iOS and Android systems automatically identify the type of Bluetooth headset and adjust the audio routing strategy. For example, when iOS detects an HFP/HSP protocol connection, it automatically disables the system's underlying software AEC.
However, desktop browsers do not have this capability. When calling getUserMedia, Chrome/Chromium (and Electron based on it) does not evaluate the current device's hardware capabilities; it only respects the constraints you pass. If you enable noise cancellation, it enables it; if you don't, it doesn't. This is why the same AirPods work fine on mobile apps but result in silence on Electron desktop clients.
3. Solution: Dynamically Switch Strategies Based on Device Type
The core idea is simple: First, detect if the user's current device is a Bluetooth headset. If it is, completely disable the browser's noise cancellation and hand the audio processing rights entirely over to the headset itself.
3.1 Device Detection
Identify device labels (Label) by enumerating MediaDevices:
typescript
function isBluetoothHeadset(label?: string): boolean {
if (!label) return false
const lower = label.toLowerCase()
const keywords = [
'airpods', 'bluetooth', 'hands-free',
'headset', 'headphone', 'earbuds', 'beats', 'freebuds'
]
return keywords.some(k => lower.includes(k))
}
// Get all audio input and output devices
const inputs = await navigator.mediaDevices.enumerateDevices()
.then(devices => devices.filter(d => d.kind === 'audioinput'))
const outputs = await navigator.mediaDevices.enumerateDevices()
.then(devices => devices.filter(d => d.kind === 'audiooutput'))
[!IMPORTANT]
Key Point: Inspect not only input devices but also output devices. The user's actual use case is usually: "I listen with AirPods (output), and I also want to capture audio with them (input)."
Therefore, if the default output device is a Bluetooth headset, we should prioritize choosing the corresponding Bluetooth input device.
javascript
async function resolvePreferredInput() {
const [inputs, outputs] = await Promise.all([
getAudioInputs(),
getAudioOutputs()
])
// Determine if the current default output is a Bluetooth device
const defaultOutput = outputs.find(
d => d.deviceId === 'default' || d.deviceId === 'communications'
)
const preferBluetooth = isBluetoothHeadset(defaultOutput?.label)
// Find the Bluetooth input device
const bluetoothInput = inputs.find(d => isBluetoothHeadset(d.label))
const defaultInput = inputs.find(
d => d.deviceId === 'default' || d.deviceId === 'communications'
)
// Routing strategy: output is Bluetooth -> prioritize Bluetooth input; otherwise, use system default input
const preferred =
(preferBluetooth ? bluetoothInput : null) ||
defaultInput ||
bluetoothInput ||
null
return { isBluetooth: !!preferBluetooth, device: preferred }
}
3.2 Dynamic Configuration of Constraints
After obtaining the device information, generate different getUserMedia constraints based on whether it is a Bluetooth device:
javascript
const { isBluetooth, device } = await resolvePreferredInput()
const captureOptions = {
deviceId: { exact: device.deviceId },
// Key difference is here 👇
autoGainControl: !isBluetooth,
echoCancellation: !isBluetooth,
noiseSuppression: !isBluetooth,
voiceIsolation: !isBluetooth // For new operating systems supporting Voice Isolation
}
- Bluetooth Headset: All four noise suppression controls are off, letting the hardware DSP take full charge.
- Built-in/Ordinary Microphone: All four options are enabled, relying on the browser's software algorithms to compensate.
3.3 Probe: Activating Hardware Channels
There is another detail that is easily overlooked: macOS activation of Bluetooth audio devices is delayed. Sometimes, although the device appears in the list, the physical audio channel has not actually been woken up yet.
Before officially publishing the audio track, we can first make a brief "probe" request:
javascript
async function probeMicrophone(constraints) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: constraints,
video: false
})
// Stop all audio tracks immediately to "wake up" the device channel without producing an actual audio stream
stream.getTracks().forEach(track => track.stop())
return true
} catch (error) {
console.warn('Probe failed, the device may be unavailable:', error)
throw error
}
}
// Actual usage flow
await probeMicrophone(captureOptions) // Wake up and probe first
const publication = await room.localParticipant.setMicrophoneEnabled(true, captureOptions)
- If permissions are denied or the device is occupied, errors can be caught before entering the call, rather than throwing hard-to-locate exceptions inside the SDK.
- Force triggering macOS audio routing switch to ensure the Bluetooth channel is truly activated.
- Probing usually completes within tens of milliseconds, completely imperceptible to users.
4. Complete Implementation Architecture
Tying the above steps together, the entire audio initialization process is as follows:
5. Pitfalls Encountered
Pitfall 1: deviceId: 'default' Does Not Equal Current "System Default"
In Chrome, default is a virtual device ID. When you pass { deviceId: { exact: 'default' } } directly to getUserMedia, it may resolve to the previously cached device rather than the system's actual current default device.
- Correct Practice:
javascript
deviceId: preferredDevice?.deviceId !== 'default' ? { exact: preferredDevice.deviceId } : { ideal: 'default' }
Pitfall 2: enumerateDevices() Requires Prior Authorization
Before the user grants microphone permissions, the label fields in the device list returned by enumerateDevices() are all empty strings. Therefore, device detection must be placed after permissions acquisition (you can first get permissions with a default constraint getUserMedia, then re-enumerate devices and perform the second accurate capture).
Pitfall 3: Different Bluetooth Brands Use Different Keywords
AirPods' labels might be "AirPods Pro", "AirPods-Left"; Huawei FreeBuds is named "HUAWEI FreeBuds 4"; Sony contains "WH-1000XM".
- Solution: The keyword library needs continuous maintenance, or fallback logic should be added: if the device name contains generic terms like
"headset","ear","buds","hands-free", treat them all as Bluetooth devices.
6. Summary
Audio compatibility of WebRTC on desktop is an issue that is often overlooked but severely impacts user experience.
The essence of the problem is: the browser treats all audio input devices as "bare microphones", attempting to apply the same software noise suppression (DSP) strategy. However, modern Bluetooth headsets are no longer just "microphones", but small computers equipped with complete audio processing pipelines.
The solution is not complex, requiring only three steps:
- Identify: Determine if a Bluetooth headset is being used through device enumeration and label matching.
- Adapt: If it is a Bluetooth device, disable the browser's noise cancellation and let the headset hardware manage it entirely.
- Probe: Perform an extremely short capture wake-up before publishing to expose permission issues and unactivated channels in advance.
These three steps add up to less than a hundred lines of code, but they can elevate the desktop calling experience of mainstream Bluetooth headsets like AirPods from "completely unusable" to "as good as on mobile".