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();
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);
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; });
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' });
}
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;
}
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();
}
}
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 }
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
getDisplayMediadiffers; 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
endedon 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)