Skip to main content
Version: 0.3.0

Known Issues & Workarounds

WebRTC Echos on Chromium-based browsers​

🐛 Bug Description​

In a web communication setting, once a participant listens to the conversations via loudspeakers, the other participants hear their own voices as the cross-talk isn't cancelled.

ℹī¸ Info​

This bug only occurs in Chromium-based browser like Chrome or Edge. Chromium doesn't apply acoustic echo cancellation (AEC) to audio played back via the WebAudio API. AEC is only applied to streams played back via HTML Media elements.

Other browsers like Safari or Firefox already apply AEC also to WebAudio API signals.

This chromium issue is known for several years and is still open. Please see the Official Bug Report.

⤴ī¸ Workaround​

A working solution for this problem is to create a local WebRTC loopback connection, feeding it with a MediaStreamAudioDestinationNode, and playing back the received signals via an HTML <audio> element. With that workaround the browser applies AEC as the audio comes from a WebRTC connection and is played back via an audio element.

The source for that workaround is this GitHub Gist which is also linked in the bug report's conversation.

To make the loopback work with stereo signals, the Session Description Protocol (SDP) has to be adjusted during the connection establishment process. Please find the code example below for a possible integration.

🧑‍đŸ’ģ Code example​

import { detect } from "detect-browser"

let context = new AudioContext()

// we only need to apply this workaround with chrome or edge
const isChromium = detect().name === 'chrome' || detect().name === 'edge'
let destination: MediaStreamAudioDestinationNode | AudioDestinationNode

// set destination depending on browser
if (isChromium)
destination = context.createMediaStreamDestination()
else
destination = context.destination

// audio setup
// ...
renderer.connect(destination)
//

if (isChromium) {
let loopbackStream = new MediaStream();

const mediaStreamDestination = destination as MediaStreamAudioDestinationNode

let rtcConnection = new RTCPeerConnection();
let rtcLoopbackConnection = new RTCPeerConnection();

// setup handling of ICE events
rtcConnection.onicecandidate = (e) => e.candidate && rtcLoopbackConnection.addIceCandidate(new RTCIceCandidate(e.candidate));
rtcLoopbackConnection.onicecandidate = (e) => e.candidate && rtcConnection.addIceCandidate(new RTCIceCandidate(e.candidate));
rtcLoopbackConnection.ontrack = (e) => loopbackStream.addTrack(e.track) ;

rtcConnection.addTrack(mediaStreamDestination.stream.getTracks()[0]);

const offerOptions = {
offerVideo: false,
offerAudio: true,
offerToReceiveAudio: false,
offerToReceiveVideo: false,
};

rtcConnection.createOffer(offerOptions)
.then(async (offer) => {
// make it stereo
offer.sdp = offer.sdp.replace('minptime=10', 'minptime=10;stereo=1; sprop-stereo=1')
await this.rtcConnection.setLocalDescription(offer);
return offer
})
.then((offer) => this.rtcLoopbackConnection.setRemoteDescription(offer))
.then(() => this.rtcLoopbackConnection.createAnswer())
.then(async (answer) => {
// make it stereo
answer.sdp = answer.sdp.replace('minptime=10', 'minptime=10;stereo=1; sprop-stereo=1')
await this.rtcLoopbackConnection.setLocalDescription(answer); return answer
})
.then((answer) => this.rtcConnection.setRemoteDescription(answer))

// create audio element and playback loopback stream
const mediaElement = document.createElement("audio");
mediaElement.srcObject = this.loopbackStream;
mediaElement.play();
}