Capturing System Audio with getDisplayMedia
getDisplayMedia({ audio: true }) is the only browser API that will put another application’s audio on the wire, and it is also the only one that will happily hand you back the voices of the people you are talking to. This guide is part of the Screen Sharing & Content Hints guide, and it settles one problem end to end: getting the audio of a shared surface onto an existing peer connection, on the platforms where that is even possible, without adding a second audio m-line and without turning the call into a howling loop.
Context & Trade-offs
The audio member of a getDisplayMedia() request is advisory in exactly the same way displaySurface is, with one extra sting: when it cannot be satisfied the promise still resolves, with a video-only stream and no error. There are three separate capture scopes, and browsers implement them independently. Tab audio is the render-process output of one browser tab, offered as a “Share tab audio” checkbox on the picker’s tab pane in Chrome and Edge on every desktop platform. System audio is the whole OS output mix pulled through a loopback device, offered on the picker’s monitor pane on Windows and ChromeOS only. Window audio does not exist anywhere — no engine, no platform, no flag.
| Engine / platform | Tab audio | Whole-screen system audio | Window audio |
|---|---|---|---|
| Chrome/Edge, Windows | yes (checkbox) | yes (WASAPI loopback) | no |
| Chrome, ChromeOS | yes | yes | no |
| Chrome, macOS | yes | no — resolves video-only | no |
| Chrome, Linux | yes | no in practice | no |
| Firefox (all desktop) | no — audio ignored |
no | no |
| Safari 17–18, macOS | no | no | no |
| Safari, iOS | no getDisplayMedia() at all |
— | — |
Once you have an audio track, the second decision is how it reaches the peer. Calling pc.addTrack() with it creates a new transceiver and a second audio m-line: a full offer/answer round trip, a second Opus encoder, a second SSRC your media server must be configured to forward, and a second <audio> element every receiver has to render and level-balance. Mixing the display audio into the existing microphone track with a WebAudio graph avoids all of that — one encoder, one SSRC, no renegotiation, and a replaceTrack() swap whose mechanics are the same ones described in Replacing Video Tracks Without Renegotiation. What you give up is independent control at the far end: nobody can mute the music without muting you.
Mixing also changes the bitrate arithmetic. A speech-only Opus stream sits comfortably at 24–32 kbps mono; the moment a music or game soundtrack joins it, that encoder is being asked to carry a full-band stereo signal on a voice budget and it will sound like a phone call held against a speaker. Set contentHint = 'music' on the mixed track and lift maxBitrate into the 64–96 kbps range — still trivial next to the video share, and well inside Opus’s 6–510 kbps envelope. The full reasoning on rate and redundancy for that kind of content is in Tuning Opus Bitrate and FEC for Lossy Networks.
Minimal Runnable Implementation
The implementation below requests tab audio in preference to system audio, refuses to mix anything if no audio track came back, and ducks the captured audio whenever a remote participant is speaking. The suppressLocalAudioPlayback: true member is the one line most implementations miss: without it Chrome keeps playing the captured tab audio out of your own speakers while also sending it digitally, so the far end receives the same signal twice — once clean, once via your microphone 30–80 ms later, which sounds like a flanger.
// Call directly from a click handler; an await before this consumes transient activation.
async function shareWithAudio(audioSender, micTrack) {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser', frameRate: { ideal: 15, max: 30 } },
audio: {
suppressLocalAudioPlayback: true, // stop local playback of what we capture (no double-send)
echoCancellation: false, // this is a digital loopback, not a room mic
noiseSuppression: false, // NS on a music bed produces audible pumping
autoGainControl: false // never re-level content the user already mixed
},
systemAudio: 'exclude', // keep the picker on tab audio; whole-screen audio self-captures
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include'
});
const [sysTrack] = stream.getAudioTracks();
// Firefox, Safari and macOS whole-screen shares land here with an empty array and no error.
if (!sysTrack) return { stream, mixer: null };
const ctx = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
await ctx.resume(); // autoplay policy suspends a context created outside a gesture
// Mic already carries the browser's AEC output; feeding it through WebAudio does not undo that.
const micGain = new GainNode(ctx, { gain: 1.0 });
const sysGain = new GainNode(ctx, { gain: 0.6 }); // headroom so the bed sits under speech
const dest = ctx.createMediaStreamDestination();
ctx.createMediaStreamSource(new MediaStream([micTrack])).connect(micGain).connect(dest);
ctx.createMediaStreamSource(new MediaStream([sysTrack])).connect(sysGain).connect(dest);
const mixed = dest.stream.getAudioTracks()[0];
mixed.contentHint = 'music'; // widen the Opus band; 'speech' would low-pass the bed
await audioSender.replaceTrack(mixed); // same m-line, same SSRC, no offer/answer
const p = audioSender.getParameters();
if (!p.encodings?.length) p.encodings = [{}]; // Firefox can return an empty encodings array
p.encodings[0].maxBitrate = 80_000; // 64–96 kbps stereo; 32 kbps voice budget is not enough
await audioSender.setParameters(p);
// Ramp, never step: a hard gain change on a loud bed clicks and can trip the far-end AGC.
const duck = (on) => sysGain.gain.setTargetAtTime(on ? 0.05 : 0.6, ctx.currentTime, 0.08);
// One termination path for every way the share can end.
const finish = async () => {
sysTrack.stop();
await audioSender.replaceTrack(micTrack); // restore the raw mic before tearing the graph down
await ctx.close(); // leaking contexts hits Chrome's ~6-per-page ceiling
};
stream.getVideoTracks()[0].addEventListener('ended', finish);
return { stream, mixer: { ctx, duck, finish } };
}
The graph has one non-obvious lifetime rule: a MediaStreamAudioSourceNode holds a strong reference to its stream, but the destination track stops producing samples the moment the AudioContext is closed or suspended. If you close the context before swapping the sender back to the raw microphone, the far end gets several hundred milliseconds of silence, and on some Chromium builds the sender latches onto an ended track and never recovers. Always replaceTrack(micTrack) first, ctx.close() second.
Reproduction Steps & Debugging Log Patterns
- On Windows Chrome, start a two-party call, share the entire screen, and tick “Also share system audio”. Confirm
stream.getAudioTracks().length === 1and loggetSettings(). - Ask the remote peer to speak continuously while you leave your speakers on at a normal level. Do not use headphones — headphones hide the entire failure.
- Poll
getStats()at 1 s intervals and recordmedia-sourceaudioLevelandtotalAudioEnergyon your outbound audio. - Watch the remote participant’s client: they should hear their own voice returning 250–450 ms late. That single lap is the diagnostic; if their microphone then re-captures it, the ring closes and the level climbs on every lap.
- Repeat with
systemAudio: 'exclude'and a tab share. The returning voice disappears entirely, because a tab capture cannot reach the conferencing tab’s own playback.
// Run alongside the 1 s stats poll: a monotonically rising level with nobody new speaking is the loop.
async function auditMixedAudio(pc) {
const report = await pc.getStats();
let src = null, out = null, worstInbound = 0;
for (const r of report.values()) {
if (r.type === 'media-source' && r.kind === 'audio') src = r; // level entering the encoder
if (r.type === 'outbound-rtp' && r.kind === 'audio') out = r; // what actually left
if (r.type === 'inbound-rtp' && r.kind === 'audio') {
worstInbound = Math.max(worstInbound, r.audioLevel ?? 0); // are they talking right now?
}
}
console.log({
sendLevel: src?.audioLevel?.toFixed(3), // 0.0–1.0; rising while you are silent = loopback
remoteLevel: worstInbound.toFixed(3), // gate the duck on this
kbps: out && Math.round(out.bytesSent * 8 / 1000), // cumulative; diff it between polls
// Chrome only reports ERLE when the browser's own AEC is in the path — never for loopback audio.
erle: src?.echoReturnLossEnhancement ?? 'n/a'
});
}
A healthy tab-audio share logs a sendLevel that tracks your speech and a flat remoteLevel when nobody else is talking. The loop signature is unmistakable once you know it:
// t+0s { sendLevel: '0.041', remoteLevel: '0.180', erle: 'n/a' } // remote starts talking
// t+1s { sendLevel: '0.166', remoteLevel: '0.184', erle: 'n/a' } // their voice is now in OUR uplink
// t+2s { sendLevel: '0.309', remoteLevel: '0.291', erle: 'n/a' } // second lap, both levels climbing
// t+3s { sendLevel: '0.612', remoteLevel: '0.588', erle: 'n/a' } // third lap — audible howl
That last point is the one worth internalising. The browser’s acoustic echo canceller works because it is handed a time-aligned reference of what the speakers are playing and subtracts it from the microphone signal; the mechanics are laid out in Audio Focus & Echo Cancellation Across Devices. A WASAPI loopback tap is not a microphone. It is a copy of the mix taken after the canceller has already done its job, so there is nothing to subtract and nothing to align. No constraint, no flag, and no amount of echoCancellation: true on the display audio track will help.
Common Implementation Mistakes
- Treating a missing audio track as an exception.
getDisplayMedia({ audio: true })resolves normally on Firefox, Safari, macOS whole-screen shares, and every window share. Fix: branch onstream.getAudioTracks().lengthand ship a working video-only path. - Omitting
suppressLocalAudioPlayback. The captured audio keeps playing locally, your microphone picks it up, and the far end receives the same signal twice offset by 30–80 ms. Fix: set it totrueon every tab-audio capture. - Requesting whole-screen system audio by default. On Windows that hands you your own downlink and starts the ring above. Fix: default
systemAudio: 'exclude'and only offer the monitor path behind an explicit “I am on headphones” confirmation. - Adding the display audio as a second track.
pc.addTrack()forces renegotiation mid-share and leaves you with two audio m-lines that most media servers and mobile clients will render inconsistently. Fix: mix into the existing sender withreplaceTrack(). - Leaving the encoder on a speech budget. A mixed music bed through a 32 kbps mono Opus configuration sounds worse than no audio at all. Fix:
contentHint = 'music'plusmaxBitrateof 64–96 kbps, and disable the capture-side noise suppression that would otherwise pump on sustained tones. - Leaking
AudioContextinstances. One context per share, never closed, hits Chrome’s per-page ceiling after roughly six shares and every subsequent constructor throws. Fix: close it in the same handler that restores the microphone track.
FAQ
Can I capture system audio on macOS at all?
Not from the browser. Chrome on macOS exposes tab audio only, and there is no flag that changes it — the platform has no user-space loopback device the way Windows does. The standard workaround is a virtual audio driver such as BlackHole or Loopback, which installs an output device the user routes their apps into; it then appears in enumerateDevices() as an ordinary audioinput, so you capture it with getUserMedia({ audio: { deviceId } }) and mix it exactly as above. That is a support burden you are choosing to take on, not an API you are calling.
Should I send a separate audio m-line instead of mixing?
Only if independent far-end control is a product requirement — for example a music-performance app where listeners need to balance the backing track against the vocalist. It costs a renegotiation, a second Opus encoder, and media-server configuration to forward two audio streams per publisher. For the ordinary “share this video with sound” case, a single mixed track is strictly less machinery. If you do go the separate route, treat the content track as a music source and disable the whole capture-side processing chain, as covered in Disabling Audio Processing for High-Fidelity Music.
Why does the shared audio sound thin and pumping even on a good connection?
Two causes, usually together. The capture-side noiseSuppression and autoGainControl you inherited from your microphone constraints are being applied to a music signal, and the suppressor reads sustained tones as stationary noise. Then the mixed track is being encoded with a speech content hint on a 32 kbps budget, which low-passes everything above roughly 8 kHz. Turn both processors off in the display-audio constraints and raise the encoder budget before you go looking at packet loss in Interpreting getStats() for Congestion Signals.
Related: return to Screen Sharing & Content Hints, then read Sharing a Tab vs a Window vs the Whole Screen for the scope decision that determines your audio options, and Keeping Shared Text Readable with contentHint for the video side of the same share.