绘制声波图(获取音频回调)
在纯音频连麦的过程中,我们经常有这种需求,展示当前是谁在发言,比如当某人说话时就在他的麦克风图标上做高亮处理。为了实现这种需求,我们就需要实时地去获取一个媒体流中正在播放的音频数据。在我们的场景中,这种设计可能显得有些多余,但我们仅仅是为了演示这个功能,所以就来绘制一个实时的声波图吧。
在绘制之前,我想先介绍一下我们 SDK 提供的和音频回调相关的 API,它们分别是:
- getCurrentTimeDomainData 获取当前音频的时域数据
- getCurrentFrequencyData 获取当前音频的频域数据
- onAudioBuffer 获取音频 PCM 数据
这 3 个方法的详细说明可以参见 stream 对象。这里我们主要讨论这 3 种音频采集方法的区别,以及在什么情况下使用哪种方法。
我们知道,音频数据是根据采样率的大小在一个数组里按时间顺序填充的采样数据,播放音频时,也会时序地将这个数组中的数据按批次取出并送入声卡中,声卡中正在处理的那一批音频数据,就是我们在那一刻听到的声音。所以这里的第一个方法 getCurrentTimeDomainData,就是实时地去获取当前正在处理的音频数据(实际上这并不是声卡当前正在处理的数据,只是尽量精确的一个离当前播放 buffer 最近的范围为 2048 长度的音频)。不过这里要注意,我们不能通过不断地调用这个方法来收集音频的原始数据,这个方法仅用于一些实时的音频分析和处理(比如我们绘制声波图),如果想要收集音频的原始数据,使用我们的第三个方法 onAudioBuffer,这个函数的返回不能保证实时性,但是会根据播放的进度不断地将之前用于播放的音频数据回调回来。好了,这样我们还剩下最后一个方法没有介绍,其实很简单,第二个方法 getCurrentFrequencyData 就是将当前的时序音频数据做了一次 STFT 变换得到的频域数据,一般可用用来绘制频谱图,或者用来判断某个频段是否有声音(是否有人说话)。
介绍完了我们 SDK 提供的方法,回到我们的场景,这里需要用到的其实就是获取当前的时域数据(getCurrentTimeDomainData
) 这个方法。下面看代码吧,首先在 room.html
里创建 2 个 canvas
对象用于绘制我们的声波图。
<!-- room.html -->
...
<div id="localplayer" class="mini-player">
<canvas width="300" height="200" id="localwave"></canvas>
</div>
<div id="remoteplayer" class="fullscreen-player">
<canvas width="640" height="480" id="remotewave"></canvas>
</div>
...
在 room.css 中添加相应的 css,注意这里我们固定了 canvas 的宽高,因为 canvas 跟随窗口动态宽高太过复杂,这里就不赘述了。
/* css/room.css */
canvas {
position: absolute;
top: 0;
left: 0;
}
有了 canvas 之后,就可以开始绘制了,绘制的过程设计到本地音频的绘制和远端音频的绘制,所以我们还是先将绘制操作抽成一个公共的函数,在 room.js
一开始加入如下代码。
// js/room.js
// 绘制声波图,stream 为需要实时绘制的媒体流对象
// ctx 为 canvas 的 context 对象,用来区分画在哪个 canvas 上
function drawAudioWave(stream, ctx) {
// 如果没有流或者流被释放了(远端取消发布等情况)就直接返回
if (!stream || stream.isDestoryed) {
return;
}
// 获取当前实时的时域数据
const timeData = stream.getCurrentTimeDomainData();
// 以下为 canvas 相关的绘制代码
const width = ctx.canvas.width;
const height = ctx.canvas.height;
ctx.fillStyle = "#000";
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2;
ctx.fillRect(0, 0, width, height);
ctx.beginPath();
for (let i = 0; i < width; i += 1) {
const dataIndex = Math.round(i * (timeData.length / width));
const data = Math.round(timeData[dataIndex] * (height / 255.0));
if (i === 0) {
ctx.moveTo(i, data);
} else {
ctx.lineTo(i, data);
}
}
ctx.stroke();
// 调用 requestAnimationFrame 逐帧更新
requestAnimationFrame(() => drawAudioWave(stream, ctx));
}
有了绘制函数,接下来就在相应的地方调用它吧
// js/room.js
function subscribeUser(myRTC, user) {
if (!user.published) {
return;
}
myRTC.subscribe(user.userId).then(remoteStream => {
const remotePlayer = document.getElementById('remoteplayer');
remoteStream.play(remotePlayer);
// 修改订阅函数,如果订阅的目标流没有开启视频(纯音频)
// 就绘制声波图
if (!remoteStream.enableVideo) {
const ctx = document.getElementById('remotewave').getContext('2d');
drawAudioWave(remoteStream, ctx);
}
}).catch(e => {
console.log('subscribe error!', e);
});
}
...
...
const localPlayer = document.getElementById('localplayer');
localStream.play(localPlayer, true);
await myRTC.publish(localStream);
// 如果本地发布的流没有开启视频采集
// 就绘制本地的声波图
if (!enableVideo) {
const ctx = document.getElementById('localwave').getContext('2d');
drawAudioWave(localStream, ctx);
}
...
...
好了,现在再使用纯音频连麦进入的话,就可以看到自己和远端的声波图啦。这里是用了最简单的方法绘制的声波图,仅仅展示 API 用,使用频域和一些算法搭配绘制会有更好的效果,这里就不赘述了。