Sharing Audio with a Screen

A screen share usually means a picture. You select a tab, a window, or a monitor, and the other peer sees what you see. But a slide deck with a video, a music app, a game, or a YouTube clip carries sound too. If you share only the picture, the far end watches a muted video and hears nothing.

This page covers capturing that audio. The goal is to send what is playing on the shared surface, not just what is drawn on it. You ask getDisplayMedia for an audio track alongside the video track, and, when the platform allows it, the browser hands you the system or tab audio as a regular MediaStreamTrack.

The hard truth up front: screen audio is best-effort. The spec lets a browser return audio, but it does not require it. Whether you get a track depends on the browser, the operating system, and the surface the user picked. You must check what you got and degrade gracefully when you got nothing.

The data path stays peer-to-peer. The captured audio rides an RTCPeerConnection as SRTP, encoded with Opus, exactly like microphone audio. Signaling carries only SDP. No sample transits a server. Everything here runs on the client.

getDisplayMedia { video, audio } getUserMedia { audio } mic Video track Screen-audio track (may be null) Microphone track Web Audio graph gain → destination Mixed audio track one Opus stream RTCPeer- Connection SRTP / Opus Remote peer

The API

You request audio the same way you request video: pass audio: true to getDisplayMedia.

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: true,
});

The call must run inside a user gesture, like a click handler. The browser shows a picker. The user chooses a tab, a window, or a whole monitor, and, on browsers that support it, checks or leaves a "Share audio" box.

What comes back is one MediaStream. It always contains a video track when video was requested and granted. It contains an audio track only if the browser captured audio for the chosen surface. That is the part you cannot assume.

const video = stream.getVideoTracks();   // length 1
const audio = stream.getAudioTracks();   // length 0 or 1

Treat audio as a maybe. Read its length before you touch element zero.

For the video side of this call (displaySurface, contentHint, the OS "Stop sharing" lifecycle), see Display Capture. For mixing webcam and screen video together, see Camera and Screen.

Gesture and secure context

Two preconditions apply before any of this runs. getDisplayMedia requires a secure context: HTTPS, or localhost during development. On a plain http:// origin the method is absent or throws, and feature detection should treat that as unsupported.

The call must also originate from a user gesture. A click, a key press, or a tap handler is a gesture; a setTimeout, a promise continuation several ticks later, or page load are not. Call getDisplayMedia directly in the handler, before any await that is not part of acquiring the share, or the browser may reject the request as gesture-less. The same gesture is the right moment to resume the AudioContext you will use for mixing, since an audio context created outside a gesture starts suspended.

Why screen audio is best-effort

A microphone is one device with one driver. The browser asks the OS for it and gets a stream. Screen audio is different. The audio you want is being produced by another application, or by the whole system mixer, and the browser has to tap that output without a dedicated capture device existing for it.

That tap is an OS-level capability, and operating systems expose it unevenly. Capturing the audio of a single application window is harder than capturing a single tab the browser already renders itself. Capturing the entire system mix is harder still and on some platforms is blocked outright for privacy reasons.

So the result depends on three things at once:

Factor Effect on audio
Browser engine Chromium captures the most surfaces; Firefox and Safari capture far fewer or none.
Operating system macOS, Windows, and Linux expose system-audio taps differently; some need a virtual device.
Chosen surface A tab is the easiest to capture; a window is harder; a full monitor often yields no audio at all.

The spec phrases this as "the user agent MAY include an audio track." The word is may. There is no constraint you can set that turns may into must. Your only honest move is to check the returned stream and adapt.

The surface-vs-audio matrix

The single most useful mental model: audio availability tracks the surface type, not just the browser. A user who picks "Entire screen" usually gets no audio even in a browser that captures tab audio fine.

This matrix reflects Chromium on desktop, where support is widest. Treat it as the optimistic case. Firefox and Safari capture much less.

Surface Typical audio result (Chromium desktop) Notes
Browser tab Audio of that tab, if the user checks "Share tab audio" The reliable case. The browser owns the tab and its audio.
Application window Usually no audio Per-window OS audio capture is rarely available.
Entire screen / monitor Often no audio; whole-system audio on some OS setups Most likely to return video only.

Two takeaways. First, if you need audio, nudge the user toward sharing a tab, not a window or screen. Second, never promise audio in your UI before you have inspected the track count.

Per-engine reality

The matrix above is the Chromium picture. The other engines diverge enough to design around.

Chromium (Chrome, Edge, and derivatives). The widest support. Tab audio is dependable when the user ticks the box. System audio works on Windows and on Chrome OS, and is available on macOS in recent versions through an OS screen-capture path. Per-window audio remains the gap. This is the engine you target if audio matters, and the one whose picker actually shows the "Share tab audio" checkbox.

Firefox. Historically returns no audio track from getDisplayMedia at all, even when you request it. The video capture works; the audio member is effectively ignored on desktop. Plan for getAudioTracks().length === 0 and fall back to the microphone.

Safari / WebKit. Screen capture exists, but display audio capture has been the most limited of the three. Treat audio as unavailable and let the microphone carry the call.

The design consequence is the same across all three: write the audio path so the absence of a track is the normal, expected outcome, not an error. The Chromium-only happy path is a bonus, not the baseline.

Mono, stereo, and the audio you actually receive

When you do get a screen-audio track, its channel count depends on the source and the OS tap. Tab and system audio often arrive as stereo, since that is how the source plays. A microphone track is usually mono.

This matters at the mixing stage. If you sum a stereo screen track and a mono mic track into one destination, the mic lands centered and the screen audio keeps its stereo image, which is usually what you want. The outgoing Opus stream then carries whatever channel count the destination node produces. Stereo doubles the audio bitrate relative to mono: fine for music, wasteful for a voice-only fallback. There is no need to force a channel count by hand for a normal mix; let the destination node settle it and only intervene if you measure a bandwidth problem.

The "Share audio" checkbox is the user's, not yours

In Chromium the picker shows a "Share tab audio" / "Share system audio" checkbox. It is unchecked by default in some flows and the user can leave it off. You cannot force it. Even on a perfectly supported surface, a returned getAudioTracks() can be empty because the user never ticked the box. Always inspect the result; never assume the request implies the track.

Reading whether you got audio

The whole feature hinges on one check. After the promise resolves, count the audio tracks.

async function shareScreenWithAudio() {
  const stream = await navigator.mediaDevices.getDisplayMedia({
    video: true,
    audio: true,
  });

  const videoTrack = stream.getVideoTracks()[0];
  const audioTracks = stream.getAudioTracks();
  const hasScreenAudio = audioTracks.length > 0;

  if (hasScreenAudio) {
    console.log('Captured screen audio:', audioTracks[0].label);
  } else {
    console.log('No screen audio: surface or platform did not provide it.');
  }

  return { videoTrack, screenAudioTrack: hasScreenAudio ? audioTracks[0] : null };
}

hasScreenAudio is the branch point for everything downstream. If it is false, you either send video only or you fall back to the microphone alone. If it is true, you have a real audio track you can send, mix, or measure.

The track's label often names the source ("System Audio", "Tab audio"), which is useful for logging but not something to parse for logic.

Reacting when the user stops

Audio and video are independent tracks. The user can stop the share from the OS UI, which ends the video track and, separately, ends the audio track. Listen on both, because either can end first.

videoTrack.addEventListener('ended', () => {
  // OS "Stop sharing" was pressed.
  cleanupShare();
});

if (screenAudioTrack) {
  screenAudioTrack.addEventListener('ended', () => {
    // Audio tap dropped; video may still be live.
    handleScreenAudioGone();
  });
}

In practice the OS "Stop sharing" button ends both at once. But a defensive listener on each avoids a stale audio track lingering in your connection after the picture is gone. For the full lifecycle on the video side, see Display Capture.

The constraints that shape screen audio

A handful of constraints influence the audio you get, where they are supported. None of them can create audio on a surface that has no tap. They only refine audio that the platform already offers.

systemAudio

systemAudio is a getDisplayMedia constraint that hints whether system-wide audio should be offered. It takes "include" or "exclude".

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: true,
  systemAudio: 'include', // hint: offer system audio in the picker
});

"include" asks the browser to present system audio as an option for screen and window surfaces. "exclude" asks it to suppress that option, which you use when you only ever want tab audio and do not want to capture other apps' sound. It is a hint. A browser that cannot capture system audio ignores it.

restrictOwnAudio and the feedback problem

When you capture system audio while your own app is playing the remote peer's voice through the speakers, the system tap picks up that voice and you send it back. The far end hears themselves. This is the same echo loop a microphone creates without acoustic echo cancellation, except here the audio is captured digitally at the mixer, so the mic's AEC never sees it.

Some Chromium versions expose a restrictOwnAudio constraint to exclude the capturing page's own audio output from a system-audio capture. Where present, it stops your own tab's sound from being looped into the system tap.

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: { restrictOwnAudio: true }, // exclude this page's own output, where supported
  systemAudio: 'include',
});

Support is narrow and the property name has shifted across drafts. Feature-detect rather than depend on it, and treat the call/echo discussion below as the portable mitigation.

Standard audio constraints

The audio member can also carry the familiar capture constraints: echoCancellation, noiseSuppression, autoGainControl, sampleRate, channelCount. For screen audio these are usually the wrong defaults. System and tab audio is often music, game sound, or a video soundtrack. The voice-tuned processing that helps a microphone damages that material: noise suppression ducks sustained tones, AGC flattens dynamics, AEC distorts music.

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: {
    echoCancellation: false,
    noiseSuppression: false,
    autoGainControl: false,
  },
});

These constraints may be ignored on screen audio depending on the browser, since the audio is a tap and not a microphone. Set them anyway; where they apply, they keep the shared audio clean. The full discussion of each constraint lives in Audio Processing.

Feature detection

You cannot detect at request time whether a surface will yield audio; that is only known after the user picks. But you can detect whether the API and its constraints exist, and you can read the constraint support map.

function screenAudioSupport() {
  const hasDisplayMedia =
    !!navigator.mediaDevices &&
    typeof navigator.mediaDevices.getDisplayMedia === 'function';

  const supported =
    navigator.mediaDevices?.getSupportedConstraints?.() ?? {};

  return {
    hasDisplayMedia,
    // These flags reflect general constraint support, not screen-audio guarantees.
    echoCancellation: !!supported.echoCancellation,
    noiseSuppression: !!supported.noiseSuppression,
    autoGainControl: !!supported.autoGainControl,
  };
}

The honest UX pattern: offer the audio option, request it, then tell the user what actually happened. "Sharing screen with audio" versus "Sharing screen: this browser or surface did not provide audio" after you have read the track count. Promising audio before the check leads to silent confusion when none arrives.

Sending the tracks: they are independent

A screen share with audio gives you up to three sources: the screen video, the screen audio (maybe), and your microphone. Each is a separate MediaStreamTrack. WebRTC sends each through its own transceiver. There is no "screen stream" object on the wire: the grouping into a MediaStream is a local convenience, not something the connection preserves.

So you add each track to the RTCPeerConnection on its own.

// Video track from the screen.
pc.addTrack(videoTrack, screenStream);

// Microphone track from getUserMedia.
const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const micTrack = micStream.getAudioTracks()[0];
pc.addTrack(micTrack, micStream);

// Screen audio, only if the surface provided it.
if (screenAudioTrack) {
  pc.addTrack(screenAudioTrack, screenStream);
}

This works, but it sends two audio tracks: the microphone and the screen audio. The remote peer now decodes and plays two Opus streams. That doubles audio bandwidth, and the far end has no built-in way to balance the two. It also means a second audio transceiver in the SDP, which some simple receivers do not expect.

For the mechanics of tracks, transceivers, and replaceTrack, see Video & Audio Tracks.

The usual choice is to send one audio track: a single mix of microphone plus screen audio. That is the Web Audio job.

On the wire it is just Opus

However the audio originated (microphone, tab tap, system mixer, or a Web Audio mix), once it is a MediaStreamTrack added to the connection, WebRTC treats it identically. The browser encodes it with Opus and wraps it in SRTP. There is no "screen audio" codec or special transport. The receiving peer cannot tell a mixed track from a plain microphone track; it gets one Opus stream and plays it.

That uniformity is why mixing is the clean answer. One mixed track is one Opus encoder, one transceiver, one stream the far end renders without special handling. Two tracks mean two encoders, two transceivers, and roughly double the audio bitrate, for material the listener experiences as a single soundscape anyway. Music benefits from a higher Opus bitrate, so if the screen audio is musical, raise the encoding bitrate on the one mixed sender rather than splitting into two tracks. The encoder-side controls live with the RTCRtpSender, covered in Video & Audio Tracks.

Mixing screen audio with the microphone

Mixing combines two audio tracks into one before they leave the browser. You build a small graph in an AudioContext: each input track becomes a source node, both feed one destination node, and the destination exposes a single output track you send.

function mixAudio(screenAudioTrack, micTrack) {
  const ctx = new AudioContext();

  // Each input track wrapped as a MediaStream, then as a source node.
  const screenSrc = ctx.createMediaStreamSource(
    new MediaStream([screenAudioTrack])
  );
  const micSrc = ctx.createMediaStreamSource(
    new MediaStream([micTrack])
  );

  // Per-source gain so the mix is balanced, not just summed.
  const screenGain = ctx.createGain();
  const micGain = ctx.createGain();
  screenGain.gain.value = 0.8; // screen audio slightly under
  micGain.gain.value = 1.0;    // voice on top

  // One destination produces the single outgoing track.
  const dest = ctx.createMediaStreamDestination();

  screenSrc.connect(screenGain).connect(dest);
  micSrc.connect(micGain).connect(dest);

  const mixedTrack = dest.stream.getAudioTracks()[0];
  return { mixedTrack, ctx, screenGain, micGain };
}

Now you send one audio track, the video track, and nothing else.

const { videoTrack, screenAudioTrack } = await shareScreenWithAudio();
const micTrack = (await navigator.mediaDevices.getUserMedia({ audio: true }))
  .getAudioTracks()[0];

pc.addTrack(videoTrack, screenStream);

if (screenAudioTrack) {
  const { mixedTrack } = mixAudio(screenAudioTrack, micTrack);
  pc.addTrack(mixedTrack, screenStream);
} else {
  // No screen audio: just send the mic.
  pc.addTrack(micTrack, micStream);
}

The branch matters. When there is no screen audio, mixing a single source through a destination node is pointless overhead: send the microphone track directly. Only build the graph when you have two real sources to combine.

The gain nodes are the reason to mix in Web Audio rather than send two tracks. They let the user ride the balance live: a slider that sets micGain.gain.value and screenGain.gain.value lets a presenter duck the slide's video soundtrack while they talk over it, then bring it back up. Two separate transceivers give the far end no such control.

A live balance control is one slider feeding two gain nodes in opposite directions. Set the slider to a 0..1 value where 0 is all screen audio and 1 is all microphone.

function bindBalance(sliderEl, screenGain, micGain) {
  sliderEl.addEventListener('input', () => {
    const t = Number(sliderEl.value); // 0..1
    // Smooth the change so the level does not click.
    const now = screenGain.context.currentTime;
    screenGain.gain.setTargetAtTime(1 - t, now, 0.02);
    micGain.gain.setTargetAtTime(t, now, 0.02);
  });
}

setTargetAtTime ramps the gain toward the target over a short time constant instead of jumping. A direct assignment to gain.value mid-stream produces an audible click; the ramp removes it. The same technique gives you a pop-free mute: ramp a gain to zero rather than stopping the track. The muting and metering patterns are detailed in Audio Processing.

AudioContext may start in a suspended state until a user gesture resumes it. Call ctx.resume() from the same click that starts the share. The full Web Audio graph pattern (analyser nodes for meters, gain stages, pop-free muting) is covered in Audio Processing.

Keeping the context alive

A common mixing bug: the AudioContext and its source nodes get garbage-collected when the function returns, and the mixed track goes silent after a few seconds. Hold a reference to the context and the source nodes for as long as the share is live. Tear them down (ctx.close()) only when the share ends.

let mixState = null;

function startMix(screenAudioTrack, micTrack) {
  mixState = mixAudio(screenAudioTrack, micTrack); // keep the refs alive
  mixState.ctx.resume();
  return mixState.mixedTrack;
}

function stopMix() {
  mixState?.ctx.close();
  mixState = null;
}

What the receiving peer does with it

The far end receives the audio track through the track event on its RTCPeerConnection, the same as any remote audio. It attaches the track to an audio element and plays it. Nothing about a mixed or screen-sourced track changes that path.

pc.addEventListener('track', (event) => {
  if (event.track.kind === 'audio') {
    const el = new Audio();
    el.srcObject = new MediaStream([event.track]);
    el.play().catch(() => {
      // Autoplay blocked: wait for a user gesture, then play.
    });
  }
});

Two things the receiver should know. First, play() can reject because the browser blocks autoplay until a user gesture; handle the rejection by retrying play on the next click. Second, the receiver gets one Opus stream regardless of how many sources you mixed, so there is no per-source volume control on the receiving side; that balance had to be set by the sender's gain nodes before encoding. This is the deciding reason to mix sender-side: only the sender can separate the sources.

Echo and feedback in a call

Sharing audio inside a live call introduces two feedback paths that a silent screen share never has.

Acoustic loop through the mic. The remote peer's voice plays from your speakers. Your microphone hears it. With echoCancellation: true on the mic, the browser's AEC subtracts the known speaker output and the far end does not hear themselves. This is the normal call case and it works.

Digital loop through the system tap. If you capture system audio while in a call, the system mixer includes the remote peer's voice that your app is playing. The tap captures that voice directly from the mixer. AEC never sees it, because AEC works on the microphone path, not on a digital audio tap. You send the remote voice straight back. The far end hears themselves, clearly, with no acoustic delay to mask it.

The mitigations, in order of preference:

Approach What it does Tradeoff
Share tab audio, not system audio Captures only the chosen tab's sound, not the call playback User must pick a tab; relies on tab-audio support
restrictOwnAudio where supported Excludes the capturing page's own output from the tap Narrow, shifting browser support
Route call audio to a non-captured device Plays the remote voice somewhere the tap does not see Needs device control the web rarely has
Headset Removes the acoustic path; the system tap still loops, so combine with tab-audio Hardware-dependent

The portable rule: prefer tab audio over system audio whenever you are inside a call, because tab audio excludes the call playback by construction. System audio plus call playback is the configuration that loops.

The reason tab audio is safe is structural, not incidental. When the user shares a tab, the browser captures the audio of that one tab. Your call's audio plays in a different tab: yours. The tab tap never sees it. With system audio, the tap reads the OS mixer after every tab and app has been summed, including your call playback, so the remote voice is in the capture before your code can do anything about it. Choosing the surface is the cheapest, most portable echo control available, and it works in every engine that captures tab audio at all.

Never play your own mixed track locally

If you attach the mixed outgoing track to a local <audio> element to "monitor" it, you play the microphone and screen audio back through the speakers, the mic hears it, and you build a feedback howl. Monitor the screen audio if you must, but never the track that contains your own microphone. Outgoing tracks are for sending, not for local playback.

Putting it together

The full flow, from gesture to a single sent audio track, with every honest branch in place.

async function startSharingWithAudio(pc) {
  // 1. Request screen + audio inside a user gesture.
  const screenStream = await navigator.mediaDevices.getDisplayMedia({
    video: true,
    audio: {
      echoCancellation: false,
      noiseSuppression: false,
      autoGainControl: false,
    },
    systemAudio: 'include',
  });

  const videoTrack = screenStream.getVideoTracks()[0];
  const screenAudioTrack = screenStream.getAudioTracks()[0] ?? null;

  // 2. Always send the screen video.
  pc.addTrack(videoTrack, screenStream);

  // 3. Get the mic separately.
  const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
  const micTrack = micStream.getAudioTracks()[0];

  // 4. Branch on whether the surface provided audio.
  if (screenAudioTrack) {
    const ctx = new AudioContext();
    await ctx.resume();

    const dest = ctx.createMediaStreamDestination();
    const screenGain = ctx.createGain();
    const micGain = ctx.createGain();
    screenGain.gain.value = 0.8;
    micGain.gain.value = 1.0;

    ctx.createMediaStreamSource(new MediaStream([screenAudioTrack]))
      .connect(screenGain).connect(dest);
    ctx.createMediaStreamSource(new MediaStream([micTrack]))
      .connect(micGain).connect(dest);

    pc.addTrack(dest.stream.getAudioTracks()[0], screenStream);

    // Stop everything when the OS share ends.
    videoTrack.addEventListener('ended', () => {
      ctx.close();
      micTrack.stop();
    });

    return { ctx, screenGain, micGain }; // hold these to keep the mix alive
  }

  // No screen audio: send the mic alone.
  pc.addTrack(micTrack, micStream);
  videoTrack.addEventListener('ended', () => micTrack.stop());
  return null;
}

Note what this does not do. It does not promise audio before the check. It does not play the mixed track locally. It does not leave a second audio transceiver when one mixed track suffices. And it tears the context down when the share ends.

Recap

  • getDisplayMedia({ video: true, audio: true }) requests audio. It does not guarantee it.
  • Audio availability depends on browser, OS, and surface. A tab is the reliable case; a window usually fails; a whole monitor often returns video only.
  • Read stream.getAudioTracks().length after the promise resolves. That count is the only source of truth.
  • Video and screen-audio are independent tracks with independent ended events. Handle both.
  • systemAudio: 'include' | 'exclude' and restrictOwnAudio are hints, where supported. Feature-detect; never depend on them.
  • Set echoCancellation, noiseSuppression, and autoGainControl to false for screen audio: it is usually music or game sound, not voice.
  • To send one audio track instead of two, mix screen audio and microphone through a Web Audio AudioContext and send the destination's track.
  • Gain nodes give the user a live balance control that two separate transceivers cannot.
  • System-audio capture during a call loops the remote voice back; prefer tab audio inside calls.

Going further

Troubleshooting

Symptom Likely cause Fix
getAudioTracks() is empty Surface has no audio tap (window or full screen), or user left the box unchecked Ask the user to share a tab and tick "Share tab audio"; degrade to video-only otherwise
Audio works in Chrome, silent in Firefox/Safari Those engines capture far fewer surfaces, or none Feature-detect, set expectations in the UI, fall back to mic-only
Far end hears their own voice System-audio capture loops the call playback into the tap; AEC cannot reach it Switch to tab audio, or use restrictOwnAudio where supported
Mixed audio goes silent after a few seconds AudioContext and source nodes were garbage-collected Hold references to the context and nodes for the share's lifetime
Mixed track is silent from the start AudioContext is suspended Call ctx.resume() inside the user gesture
Music sounds muffled or pumps Voice-tuned constraints applied to screen audio Set noiseSuppression, autoGainControl, echoCancellation to false
Feedback howl on the local machine The outgoing mixed track is attached to a local audio element Never play your own mic-containing track locally
Two audio streams at the far end Both mic and screen audio added as separate tracks Mix them into one track in Web Audio and add only that
Audio track stays in the connection after sharing stops Only the video ended event was handled Listen for ended on the audio track too, or close the context on video ended