12、屏幕共享+音频:同时捕获系统音频、audio约束设置、回声消除处理
屏幕共享这件事,做到一半你会发现——光有画面不行。用户一边共享PPT,一边还要说话讲解。这时候问题就来了:系统声音要不要录?麦克风声音和系统声音混在一起怎么处理?回声怎么消除?
嗯,这一章我们就来啃这块硬骨头。我会把我在实际项目中踩过的坑、试过的方案,都摊开来讲。
12.1 同时捕获系统音频:getDisplayMedia 的 audio 参数
先看最基础的问题:怎么在共享屏幕的同时,把系统播放的声音也抓进来?
其实 getDisplayMedia 本身就支持音频捕获。你只需要在约束里把 audio 设为 true:
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true
});
就这么简单?嗯,理论上是的。但实际用起来,坑不少。
另外,audio: true 捕获的是「系统输出音频」,也就是你电脑扬声器里正在播放的声音。不是麦克风的声音。这两个概念千万别搞混。
12.2 audio 约束的精细控制
光设个 true 太粗糙了。实际场景里,你可能想控制采样率、声道数、回声消除等参数。这时候就要用更精细的约束写法:
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
sampleRate: 48000,
channelCount: 2,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
这里每个参数都有讲究:
| 参数 | 说明 | 我的建议值 |
|---|---|---|
| sampleRate | 采样率,单位 Hz | 48000(音质好,兼容性高) |
| channelCount | 声道数,1 为单声道,2 为立体声 | 2(屏幕共享通常有立体声需求) |
| echoCancellation | 回声消除 | true(后面细讲) |
| noiseSuppression | 噪声抑制 | true |
| autoGainControl | 自动增益控制 | true |
getSupportedConstraints() 检查浏览器支持哪些参数。不同浏览器支持的约束项不一样,提前检查可以避免运行时出错。
12.3 回声消除:为什么需要它?
你想想看,屏幕共享的场景下,用户通常开着扬声器。系统音频从扬声器放出来,又被麦克风收进去——这就形成了回声。如果不处理,远端的人会听到自己声音的回声,体验极差。
回声消除(AEC,Acoustic Echo Cancellation)就是干这个的。它通过算法把麦克风采集到的回声信号抵消掉。
在 WebRTC 里,回声消除通常由浏览器底层实现。你只需要在约束里设置 echoCancellation: true 就行。但这里有个关键点:
getDisplayMedia 或 getUserMedia 的约束里设置,而不是在 audio 标签或 RTCPeerConnection 里设置。
我曾经在一个项目里,把回声消除加在了远端音频播放的 audio 标签上——结果完全没用。后来才意识到,AEC 必须在采集阶段就介入。
12.4 屏幕共享 + 麦克风:双音频流的合并
现在问题来了:屏幕共享捕获的是系统音频,但用户还要说话。这两个音频流怎么合并?
最直接的做法:用 MediaStream 的 addTrack 方法,把两个流的音频轨道合并到一起。
// 获取屏幕共享流(含系统音频)
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: {
echoCancellation: true,
noiseSuppression: true
}
});
// 获取麦克风流
const micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
// 合并两个流
const combinedStream = new MediaStream();
// 添加屏幕共享的视频轨道
displayStream.getVideoTracks().forEach(track => {
combinedStream.addTrack(track);
});
// 添加屏幕共享的音频轨道(系统音频)
displayStream.getAudioTracks().forEach(track => {
combinedStream.addTrack(track);
});
// 添加麦克风的音频轨道
micStream.getAudioTracks().forEach(track => {
combinedStream.addTrack(track);
});
合并之后,combinedStream 就包含了三个轨道:一个视频轨道、两个音频轨道。你可以直接把它传给 RTCPeerConnection 的 addStream 或 addTrack。
12.5 更优雅的方案:使用 AudioContext 混音
如果你想要更精细的控制,可以用 AudioContext 把两路音频混成一路。这样远端只需要处理一个音频轨道,更干净。
const audioContext = new AudioContext();
// 创建两个源节点
const displaySource = audioContext.createMediaStreamSource(displayStream);
const micSource = audioContext.createMediaStreamSource(micStream);
// 创建混音节点
const merger = audioContext.createChannelMerger(2);
// 连接:显示音频 -> 混音器
displaySource.connect(merger, 0, 0);
// 连接:麦克风音频 -> 混音器
micSource.connect(merger, 0, 1);
// 创建目标流
const dest = audioContext.createMediaStreamDestination();
merger.connect(dest);
// 最终流:视频来自 displayStream,音频来自混音器
const finalStream = new MediaStream();
displayStream.getVideoTracks().forEach(track => {
finalStream.addTrack(track);
});
dest.stream.getAudioTracks().forEach(track => {
finalStream.addTrack(track);
});
这个方案的好处是:你可以控制每路音频的音量、延迟、甚至加特效。我在做在线教育项目时,就用这个方案实现了「老师声音优先于系统音频」的效果——当老师说话时,自动降低系统音量。
12.6 知识体系总览
下面这张图把本章的核心逻辑串起来了:
12.7 避坑指南
最后,分享几个我实际踩过的坑:
- 回声消除不是万能的。 如果用户把扬声器音量开到最大,AEC 算法也扛不住。我建议在 UI 上提示用户「建议使用耳机」。
- 两个音频轨道同时传输时, 远端可能会听到回声。因为系统音频里可能已经包含了用户的声音。解决办法:让用户戴耳机,或者用 AudioContext 混音后只传一路。
- 采样率不匹配 会导致音频失真。如果麦克风是 44100Hz,系统音频是 48000Hz,合并前最好统一采样率。AudioContext 的
createBuffer可以帮你做重采样。 - 我曾经遇到过一个诡异的问题: 在 Windows 上,
getDisplayMedia的audio: true捕获到的系统音频有 2 秒延迟。后来发现是声卡驱动的问题,换了个驱动就好了。所以如果遇到延迟,先检查驱动。
嗯,这一章的内容就到这里。屏幕共享加音频,说白了就是「捕获 → 处理 → 合并 → 传输」这四个步骤。每一步都有细节,但掌握了核心逻辑,你就能应对大部分场景。