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
What each display-audio capture scope can actually reach The operating system output mix contains the browser, which in turn contains the shared tab, the conferencing tab, other tabs and notification chimes, alongside non-browser applications. Tab audio reaches only the shared tab. Whole-screen system audio reaches every box including the conferencing tab, which is the source of the feedback loop. Window shares carry no audio on any platform. Capture scope containment — the picker choice decides the blast radius OS output mix — what a loopback device sees browser audio Shared tab media player Your conf tab remote voices Other tabs autoplay video Notifications chimes Chat app alert sound Music app background Tab audio — all desktop Chromium reaches: the shared tab, nothing else no self-capture, no loop by construction System audio — Windows / ChromeOS reaches: every box on the left, including your own conference tab this is where the howl comes from Window share — no audio on any platform Firefox ignores the audio member entirely; Safari rejects or drops it. Both resolve with a video-only stream and no error. So the only safe test is stream.getAudioTracks().length — never the absence of an exception. Design the feature so a video-only result is a normal outcome, not an error path.
The picker pane the user lands on determines whether your own downlink audio is inside the capture, and that single fact drives every mitigation below.

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 mixing graph: two sources, two gains, one destination track The microphone track and the display audio track each pass through a gain node into a MediaStreamAudioDestinationNode. Its output stream provides a single mixed track that replaces the existing sender track. A one second poll of remote audio level drives the display gain down to near zero while anyone else is speaking. AudioContext at 48 kHz — one graph, one outbound Opus encoder mic track AEC + NS applied display audio track raw, unprocessed GainNode gain 1.0 fixed GainNode 0.6 → 0.05 ducked MediaStream DestinationNode sums to stereo 48 kHz, no clipper sender.replaceTrack() same m-line, same SSRC contentHint "music" maxBitrate 80 kbps remote audioLevel poll, 1 s cadence any inbound level > 0.02 → setTargetAtTime(0.05, 0.08) Sum both gains ≤ 1.6 or the destination node clips before Opus ever sees the signal.
Two sources, two gain stages, one destination — the ducking control is the only moving part, and it moves on a time constant, not a step.

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

  1. On Windows Chrome, start a two-party call, share the entire screen, and tick “Also share system audio”. Confirm stream.getAudioTracks().length === 1 and log getSettings().
  2. 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.
  3. Poll getStats() at 1 s intervals and record media-source audioLevel and totalAudioEnergy on your outbound audio.
  4. 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.
  5. 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
The system-audio feedback ring and the three places to cut it A closed ring of five stages: remote voice plays out on your speakers, the operating system loopback captures it, your uplink re-sends it, the remote participant hears their own voice, and their microphone re-captures it. One lap takes roughly 250 to 450 milliseconds and a loop gain above one produces an audible howl within three or four laps. Three mitigations cut the ring at different points. Why whole-screen audio howls and tab audio does not Remote voice on your speakers OS mixer loopback capture Mixed into your uplink Remote hears its own voice Remote mic re-captures it one lap ≈ 250–450 ms SFU forwarding 1–3 ms; the rest is jitter buffer and playout latency Cut 1 — never enter the ring systemAudio: "exclude", share tab audio removes the OS mixer stage entirely Cut 2 — stop the acoustic path suppressLocalAudioPlayback: true kills the duplicate mic-borne copy Cut 3 — drive loop gain below 1 duck sysGain to 0.05 while any inbound audioLevel exceeds 0.02 half-duplex, but it never howls No browser AEC covers this path: the canceller models the microphone, and the loopback tap sits downstream of it.
Loopback audio bypasses the echo canceller completely, so the only defences are structural — pick a narrower scope, or hold the loop gain under unity.

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

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.