DEV Community

anders lee
anders lee

Posted on

Capturing tab audio with getDisplayMedia: the parts the docs gloss over

Users of my live-captioning app, LiveToka, kept asking the same thing: "How do I translate the YouTube video / Google Meet call that's playing in my other tab?" The classic answer is a virtual audio cable (VB-CABLE on Windows, BlackHole on Mac). It works, but asking people to install an audio driver loses most of them.

Chromium browsers can do it without any extension: navigator.mediaDevices.getDisplayMedia() lets the user pick a tab and share its audio. Here's what I learned wiring it into a real-time audio pipeline.

The minimal version

const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: true,
});
const [track] = stream.getAudioTracks();
Enter fullscreen mode Exit fullscreen mode

The user gets the browser's share picker, chooses a tab, ticks Share tab audio, and you get a MediaStream. Feed it into Web Audio like a microphone:

const ctx = new AudioContext({ sampleRate: 48000 });
const source = ctx.createMediaStreamSource(stream);
source.connect(myProcessingNode);
Enter fullscreen mode Exit fullscreen mode

Simple. Now the details.

1. You must ask for video, even if you only want audio

In Chrome, getDisplayMedia({ audio: true }) without video is rejected. Ask for video: true and ignore the video track. I disable it rather than stopping it:

stream.getVideoTracks().forEach(t => { t.enabled = false; });
Enter fullscreen mode Exit fullscreen mode

In my testing, stopping the video track could end the whole share in some setups; disabling it is the conservative option and costs very little.

2. "Granted" doesn't mean you got audio

If the user picks a window or forgets the Share tab audio checkbox, the promise resolves happily with a stream that has no audio track. Check explicitly and explain:

if (!stream.getAudioTracks().length) {
  stream.getTracks().forEach(t => t.stop());
  throw Object.assign(new Error('No audio was shared'), { name: 'NoSharedAudioError' });
}
Enter fullscreen mode Exit fullscreen mode

Then show something actionable: "Choose a tab and turn on 'Share tab audio'." Distinguish it from NotAllowedError, which here usually means the user closed the picker — not a permission problem, so don't tell them to change site settings.

3. It needs a user gesture — and async code eats gestures

getDisplayMedia must be called during a user activation (a click). My start handler first did a few awaits (a session API call, opening a WebSocket) and only then asked for audio. That works for getUserMedia, but it's fragile for display capture.

Fix: if the user selected "tab audio", request it first thing in the click handler, before any other await, and keep the stream for the rest of the setup.

4. Don't make the picker reappear on every restart

A live session gets restarted more than you'd think: the user changes the source language, the connection drops, the recogniser needs a new config. If each restart calls getDisplayMedia again, the user sees the picker again — infuriating mid-livestream.

Keep the display stream at module level and reuse it while its audio track is alive:

let displayStream = null;

async function getTabAudio() {
  if (displayStream?.getAudioTracks().some(t => t.readyState === 'live')) return displayStream;
  displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: { /* … */ } });
  // … checks from above …
  displayStream.getAudioTracks()[0].addEventListener('ended', onShareEnded);
  return displayStream;
}
Enter fullscreen mode Exit fullscreen mode

Your teardown code then needs to know the difference between "stop the session" (release everything) and "restart the session" (keep the shared tab). I ended up with an explicit stop({ restart: true }) path, and a cleanup that skips stopping tracks that belong to the display stream.

5. Handle the browser's own "Stop sharing" button

Chrome shows a "Stop sharing" bar. When the user clicks it, the audio track fires ended. If you ignore it, your app keeps "listening" to silence. Stop the session and say why:

function onShareEnded() {
  displayStream = null;
  if (running) {
    notify('Tab sharing ended, so listening stopped.');
    stopSession();
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Turn off voice processing for this source

getUserMedia defaults are tuned for a person talking into a microphone: echo cancellation, noise suppression, automatic gain. For tab audio they do harm — music and voices from the tab can be treated as "echo" or noise. Ask for clean audio:

audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }
Enter fullscreen mode Exit fullscreen mode

The same applies to virtual audio cables used through getUserMedia; I added a "Computer audio" toggle that flips these three flags off.

7. Stereo, sample rates and the processing node

Tab audio is usually stereo at 48 kHz. createMediaStreamSource plus a mono processing node downmixes for you. If your speech API wants 16 kHz mono PCM, downsample in your processing node and convert to Int16. (I still use a ScriptProcessorNode there for simplicity; an AudioWorklet is the modern choice and moves the work off the main thread.)

8. Know where it doesn't work

  • Mobile browsers: no tab audio capture. Hide the option on mobile instead of letting it fail.
  • macOS: Chrome offers tab audio reliably; capturing audio of the whole screen or of desktop apps is more limited. For desktop apps on a Mac, a virtual device like BlackHole is still the way.
  • Windows: sharing the entire screen can include system audio, which covers desktop apps like Zoom too.
  • Firefox and Safari: support for audio in getDisplayMedia differs; check MDN's compatibility table for current versions and feature-detect rather than sniffing user agents.

9. Make it switchable mid-session

Once you treat "microphone" and "tab audio" as two sources behind one function, you can swap them while the session runs: disconnect the old MediaStreamAudioSourceNode, create one from the new stream, connect it to the same processing node. No reconnect to the server needed. Users love being able to fix "oops, wrong source" without starting over.

Summary

  • Request video: true, disable the video track, verify there is an audio track.
  • Call it first in the click handler.
  • Reuse the stream across restarts; release it only on an explicit stop.
  • Listen for ended on the audio track.
  • Disable echo cancellation, noise suppression and AGC for this source.
  • Feature-detect and offer a virtual-cable fallback where tab audio isn't available.

If you want to see it in action, open a YouTube video in one tab and try LiveToka's "Tab or screen audio" option in another. Questions welcome in the comments.

Top comments (0)