前言
最近在做一个在线语音聊天室的项目,在此之前零经验,所以疯狂爬帖。对于 Web Audio API 来说,除了 MDN 之外,资料不是特别多,掘金 和 思否 文章相对集中,但也不全是新的,这就导致了一个刚入手就会遇到的坑。
本文重点不是介绍 Web Audio API,而是分享开发时的历程。若想获得更多知识以及技术细节,请参阅 MDN 和文末 推荐阅读 中的文章。
测试环境:
- Mozilla Firefox:v75.0
- Microsoft Edge:v81.0.416.64
getUserMedia
使用 Web Audio API 进行录音,第一步就是要拿到浏览器的录音 API 以及获得相应的录音权限,部分文章会使用 navigator.getUserMedia()
这个 API。为解决不同浏览器兼容性问题,有形如以下的代码:
navigator.getUserMedia =
navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia;
navigator.getUserMedia(constraints, sucCallBack, errCallBack);
上述代码是在三四年前(当前 2020 年)的历史条件下形成的,在 Firefox 下运行可能会有以下警告⚠️:
访问 MDN 对应页面,可以看到该 API 被 MediaDevices.getUserMedia()
取代,并且结合了 Promise。作为 Navigator 的一个子对象,调用形式如下:
navigator.mediaDevices.getUserMedia({ audio: true, video: false });
.then((stream) => {
/* 使用 stream */
})
.catch((error) => {
/* 处理 error */
});
使用 Promise 的 then()
resolve sucCallBack()
处理成功的回调,catch()
reject errCallBack()
处理失败的回调,constraints
照旧。
返回一个 Promise 对象,成功后会 resolve 回调一个 MediaStream 对象。若用户拒绝了使用权限,或者需要的媒体源不可用,promise 会 reject 回调一个
PermissionDeniedError
或者NotFoundError
。
返回的 promise 对象可能既不会 resolve 也不会 reject,因为用户不是必须选择允许或拒绝。
版本问题不能算是坑,毕竟要考虑项目的用户对象等各种原因,但对于一个刚入坑的新人来讲,很容易摸不着头脑,不知孰对孰错。
好,通过 navigator.mediaDevices.getUserMedia()
指定的 resolve 函数,现在可以得到 stream
了,这是一个 MediaStream 媒体流,可以直接作为音频或视频的播放源。那么,如何播放呢?创建 <audio>
或者 <video>
标签,填写 src
属性然后触发响应事件就行,OK 文章结束。是吗?强大的 HTML5 可不这么认为,了解了 音频图,你就会知道原来代码不仅可以作画,还可以 写音乐。
在了解播放音频之前,不妨先来看看这些音乐数据的格式。
获取数据
JS 获取二进制数据的方式不少,比较常见的就是读取为 ArrayBuffer 和 Blob。
File 对象
<input type="file" onclick="loadFile(e)">
使用 <input>
标签上传文件,然后使用 FileReader 对象读取文件。
function loadFile(e) {
let reader = new FileReader();
reader.onload = function() {
let arrayBuffer = reader.result;
// 处理 arrayBuffer
};
reader.readAsArrayBuffer(e.target.files[0]);
}
此时 arrayBuffer
是 ArrayBuffer 实例,可以通过 Blob()
构造 Blob 对象,进而创建 Blob URL。
Blob URL
URL.createObjectURL()
静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的 URL 对象表示指定的 File 对象或 Blob 对象。
reader.load = function(){
let blob = new Blob([arrBuf], {type: "audio/wav"});
let blobURL = window.URL.createObjectURL(blob);
doucment.getElementById("audio").src = blobURL;
window.URL.revokeObjectURL(blobURL); // 使用完需要释放资源
}
Blob URL 可以像一般的 HTTP URL 引用,但只能在浏览器的单个实例中和同一个会话中。而正是绑定了 document 对象,所以每次使用完尽量释放,尽管页面在关闭时浏览器会自动清理 document。
实际上 File 继承了 Blob,拥有其所有接口,所以 file
实例其实无需经过 Blob 可直接创建 Blob URL:
reader.load = function(e){
blobUrl = URL.createObjectURL(e.target.files[0]);
}
ArrayBuffer
调用 FileReader 对象的 readAsArrayBuffer()
方法后,读取 result
实例属性,可以获得转换后的 ArrayBuffer 对象,借助 TypedeArray
或 DataView
对象可实现自定义操作。
XHR
使用 HTTP 协议的 AJAX,可以发送或者接受二进制文件。
- 接受:
var XHR = new XMLHttpRequest(); XHR.open("GET", "/myfile.png", true); XHR.responseType = "arraybuffer"; XHR.onload = function(e) { let buf = XHR.response; // 获得 ArrayBuffer }
- 也可直接构造 Blob
let blob = new Blob([XHR.response], { type: "image/png" });
- 或者设置
responseType
为blob
XHR.responseType = "blob";
- 也可直接构造 Blob
- 发送:
XHR 增强的let myArray = new ArrayBuffer(512); let longInt8View = new Uint8Array(myArray); let XHR = new XMLHttpRequest(); XHR.open("POST", url, true); XHR.send(longInt8View);
send()
方法可直接发送二进制文件。
WebSocket
同样 WebSocket 的 send()
方法也可直接发送二进制格式,具体有以下类型:
- USVString
- Blob
- ArrayBuffer
而接收时,浏览器可能会进行解析,因此必要时需在连接前指定 binarytype
属性。如果发送是以 Blob,那么接收也应该按相同的格式接收。除却 USVString,两种类型大致相同,Blob 适合直接保存为文件,ArrayBuffer 适合在内存中编辑(毕竟有 TypedeArray
和 DataView
)。
音频图?
MDN 对 Web Audio API 是这样介绍的:
Web Audio API 使用户可以在音频上下文(AudioContext)中进行音频操作,具有模块化路由的特点。在音频节点上操作进行基础的音频, 它们连接在一起构成音频路由图。
下方还有这样的章节:
什么是音频图呢?乍接触到时云里雾里的,直接翻文档不知所云,所以还是得先读一读其他的文章。爬帖一波,再结合文档,我整理了如下一张图:
可以看到,AudioContext 和 AudioNode 衍生出了一系列对象。不得不说,Web 的对象和 API 真是多,而且相互交织,不画个示意图真不好理解。
创建 AudioContext
AudioContext 翻译为 音频上下文,对音频的一切操作都在这个环境里进行,就像一个工厂。自然而然的会想到,AudioContext 可以类比 Canvas 中 getContext()
。
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
有了 AudioContext 后,就可以创建音频/视频源了,来源有下面几个。
AudioBufferSourceNode
AudioBufferSourceNode 对象由 AudioContext 实例的 createBufferSource()
方法创建。
顾名思义,就是音视频的起点,是一个 AudioNode。有以下常用属性:
buffer
:是一个 AudioBuffer 对象,表示要播放的音频loop
:布尔值。是否重播,默认false
connect
:继承自 AudioNode
使用以下方法开始和暂停:
start(when[, offset][, duration])
:从when
开始播放
只能被播放一次,也就是每次start()
后,如果想再播放一遍,就需要重新创建,不过创建该节点代价不大stop([when])
:到when
停止播放
不同于start()
,可以多次调用
该节点的 buffer
属性指向一个 AudioBuffer 对象,这个对象是由 AudioContext 的方法创建的。前面提到,通过 XHR 或者 File 获得二进制文件。我们都知道音视频都有压缩(所以产生了各种压缩格式),播放前得先解码,所以下面两个函数就是用来将之解码为可播放的 buffer。
decodeAudioData()
该函数可实现从 音频文件 的 ArrayBuffer 异步解码。新版 API 使用 Promise 的形式。
XHR.onload = function(){
context.decodeAudioData(XHR.response)
.then((buffer) => {
// 处理 buffer
})
.catch((error) => {
// 处理错误
});
};
createBuffer()
新建一个空白的 AudioBuffer 对象,以便用于填充数据,通过 AudioBufferSourceNode 播放。
let ctx = new AudioContext();
let emptyBuffer = ctx.createBuffer(numOfChan, len, rate);
numOfChan
:声道数len
:长度rate
:采样频率
AudioDestinationNode
为了遵循行文逻辑,而保持层次结构上清晰,这部分见 destination
。
MediaElementAudioSourceNode
- 表示由 HTML5
<audio>
或<video>
元素生成的音频源 - 这是一个作为音频源的 AudioNode
let audioCtx = new window.AudioContext();
let audio = document.querySelector('audio');
let source = audioCtx.createMediaElementSource(audio);
MediaStreamAudioSourceNode
- 表示由 WebRTC MediaStream(如网络摄像头或麦克风)生成的音频源
- 这是一个作为音频源的 AudioNode
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
let audioCtx = new window.AudioContext();
let source = audioCtx.createMediaStreamSource(stream);
});
连接 connect()
有了起点和音源/视频源,现在可以播放了。怎么操作呢?很简单,连接到扬声器/屏幕就行了。把大象塞到冰箱里的第三步——关上冰箱门。在这里是连接到终点。
destination
所谓终点,由 AudioContext 的 destination
定义,是一个 AudioDestinationNode 对象,该对象也是个 AudioNode,一般表示最终的渲染设备。
let audioCtx = new AudioContext();
let source = audioCtx.createMediaElementSource("element");
source.connect(audioCtx.destination);
使用 disconnect()
方法断开连接,可以实现停止播放,再次 connect()
即可继续。
对于 WebRTC MediaStream,媒体流可以存储在本地文件或者被发送到另外一台计算机,使用 createMediaStreamDestination()
方法创建一个对象,关联一个音频流。
let audioCtx = new AudioContext();
let destination = audioCtx.createMediaStreamDestination();
现在 <audio>
和 <video>
标签的功能好像都实现了,但 Web Audio API 的功能远不止这些。翻看 MDN 文档,会发现有一堆 *Node
,这些对象都是继承自 AudioNode,正如示意图所呈现的。
AudioNode
该接口是处理音视频的通用模块,可以衍生出各种不同功能的模块,正如上文多次提到的“作为 AudioNode
”
- AudioBufferSourceNode,它的数据来源于一个解码好的完整的 buffer
- GainNode:用于设置音量
- OscillatorNode:用于产生周期性波形
- BiquadFilterNode:用于滤波
- ScriptProcessorNode:用 JS 处理音频,见 下文
- MediaStreamAudioSourceNode:用于连接麦克风设备
这些模块既有输入也有输出,可以用装饰者模式一层层 connect,不同的节点可以 连接 在一起构建一个处理图。
GainNode
是用于修改音量的节点,现在可以写一个音乐播放器了(逃
let gainNode = context.createGain();
source.connect(gainNode);
gainNode.connect(context.destination);
连接好后可以通过修改 value
值更改音量:
gainNode.gain.value = 0.5;
代码与艺术
借助各式各样的音频模块,处理一段音频或者生成一段音频不在话下,甚至还能用声音作画(AnalyserNode),我不通乐理、不懂声学,暂且不深入学习。分享几个与 Web Audio API 相关的网页:
若有兴趣可移步 把浏览器变成钢琴!Web Audio API入门。
录音
大致了解了 Audio API,但还没解决录音的问题,录音这部分资料不是太多,但模式大致相同。
- 使用 WebRTC 的
getUserMedia()
获取 MediaStream - 使用这个流初始化(
createMediaStreamSource()
)一个 MediaStreamAudioSourceNode 音频模块 - 将节点连接到 ScriptProcessorNode(
createScriptProcessor()
)——使用 JS 处理音频的节点 - 设置 ScriptProcessorNode 的
onaudioprocess
事件的回调函数
ScriptProcessorNode
通过这个节点,可以使用 JS 操作音频,进一步让用代码写音乐成为可能。
let audioCtx = new AudioContext();
scriptNode = audioCtx.createScriptProcessor(bufferSize, numrOfIn, numOfOut);
bufferSize
:缓冲区大小,应为 2 的幂,通常为4096
numOfIn
:输入声道数,默认2
,最高32
numOfOut
:输出声道数,默认2
,最高32
onaudioprocess
事件
该事件对象有以下两个缓冲区(AudioBuffer 对象):
inputBuffer
outputBuffer
使用 getChannelData(num)
方法可获得该 num
声道的数据,数据以 Float32Array 对象存储。
const onAudioProcess = (event) => {
let audioBuffer = event.inputBuffer;
let leftChannelData = audioBuffer.getChannelData(0),
rightChannelData = audioBuffer.getChannelData(1);
// 处理两个声道的数据
}
合并声道
各个声道是分离的,如果要存储然后播放,按时间先后排布显然是不行的,所以要合并,左右声道交叉存储和播放。
// 交叉合并左右声道的数据
const combine = (left, right) => {
let totalLength = left.length + right.length;
let data = new Float32Array(totalLength);
for (let i = 0; i < left.length; i += 2) {
data[k] = left[i];
data[k + 1] = right[i];
}
return data;
}
编码并传输
编码为 WAV 格式的函数经过不断改进,可以直接使用。编码以及压缩后的数据(Blob)可以直接保存/发送。但是在实时通信的场景下,数据应该是源源不断地产生和消耗,所以要用到流。开辟一块缓冲区,隔一段时间取出发送,并清空缓冲区,当这个间隔时间大小合适,就能产生实时的效果。
感谢腾讯 AlloyTeam 全端团队来自 2015 年的 文章,给出了 Audio API 结合 WebSocket 进行录制与传输的方案。我在此基础上稍作修改,分理出两个类,项目的烂代码还没开源,为不影响阅读暂把前人的智慧贴在文章末尾。(可自定义的参数太多了,全部提取出来有点繁琐,所以部分参数硬编码了。)
推荐阅读
注意本节内容列出阅读的文章,虽然也是写作本文的参考文章,读到这里时也建议移步吸取灵感。
- 深入浅出 Web Audio Api - 掘金
- 大话Web-Audio-Api - 掘金
- 初识HTML5 Web Audio API - 掘金
- 如何实现前端录音功能 - 掘金
- HTML5音频API Web Audio - 思否
- 基于WebSocket的在线聊天室(一) - 简书
- 基于WebSocket的在线聊天室(二) - 简书
- websocket 探索其与语音、图片的能力
- 把浏览器变成钢琴!Web Audio API入门
- Web Audio API - MDN
附录:代码
class SAudioData {
constructor(rate) {
this.size = 0; // 录音文件长度
this.buffer = []; // 录音缓存
this.inputSampleRate = rate; // 输入采样率
this.outputSampleRate = 44100 / 6; // 输出的采样率,取决于平台
this.inputSampleBits = 16; // 输入采样位数 8, 16
this.outputSampleBits = 8; // 输出采样位数 8, 16
}
// 填入缓冲区
inputData = (data) => {
this.buffer.push(new Float32Array(data));
this.size += data.length;
};
// 清理缓冲区
clearData = () => {
this.size = 0;
this.buffer = [];
};
// 合并压缩
compress = () => {
var data = new Float32Array(this.size);
var offset = 0;
for (var i = 0; i < this.buffer.length; i++) {
data.set(this.buffer[i], offset);
offset += this.buffer[i].length;
}
// 压缩
var compression = parseInt(this.inputSampleRate / this.outputSampleRate);
var length = data.length / compression;
var result = new Float32Array(length);
var index = 0,
j = 0;
while (index < length) {
result[index] = data[j];
j += compression;
index++;
}
return result;
};
// 编码为 WAV,Blob
encodeWAV = () => {
var sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
var sampleBits = Math.min(this.inputSampleBits, this.outputSampleBits);
var bytes = this.compress();
var dataLength = bytes.length * (sampleBits / 8);
var buffer = new ArrayBuffer(44 + dataLength);
var data = new DataView(buffer);
var channelCount = 1; // 单声道
var offset = 0;
var writeString = function (str) {
for (var i = 0; i < str.length; i++) {
data.setUint8(offset + i, str.charCodeAt(i));
}
};
writeString("RIFF"); // 资源交换文件标识符
offset += 4;
data.setUint32(offset, 36 + dataLength, true); // 下个地址开始到文件尾总字节数,即文件大小 -8
offset += 4;
writeString("WAVE"); // WAV 文件标志
offset += 4;
writeString("fmt "); // 波形格式标志
offset += 4;
data.setUint32(offset, 16, true); // 过滤字节,一般为 0x10 = 16
offset += 4;
data.setUint16(offset, 1, true); // 格式类别 (PCM 形式采样数据)
offset += 2;
data.setUint16(offset, channelCount, true); // 通道数
offset += 2;
data.setUint32(offset, sampleRate, true); // 采样率,每秒样本数,表示每个通道的播放速度
offset += 4;
data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); // 波形数据传输率 (每秒平均字节数) 单声道×每秒数据位数×每样本数据位/8
offset += 4;
data.setUint16(offset, channelCount * (sampleBits / 8), true); // 快数据调整数 采样一次占用字节数 单声道×每样本的数据位数/8
offset += 2;
data.setUint16(offset, sampleBits, true); // 每样本数据位数
offset += 2;
writeString("data"); // 数据标识符
offset += 4;
data.setUint32(offset, dataLength, true); // 采样数据总数,即数据总大小-44
offset += 4;
// 写入数据
if (sampleBits === 8) {
for (var i = 0; i < bytes.length; i++, offset++) {
var s = Math.max(-1, Math.min(1, bytes[i]));
var val = s < 0 ? s * 0x8000 : s * 0x7fff;
val = parseInt(255 / (65535 / (val + 32768)));
data.setInt8(offset, val, true);
}
} else {
for (var i = 0; i < bytes.length; i++, offset += 2) {
var s = Math.max(-1, Math.min(1, bytes[i]));
data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
return new Blob([data], { type: "audio/wav" });
};
}
class SRecorder {
constructor(stream) {
this.clock = null; // 循环定时器
// 音频处理接口
this.audioContext = new AudioContext();
// 通过音频流创建输入音频对象
this.audioInput = this.audioContext.createMediaStreamSource(stream);
// 创建音频数据对象
this.audioData = new SAudioData(this.audioContext.sampleRate);
// 创建音量对象
this.audioVolume = this.audioContext.createGain();
// 创建录音机对象
this.recorder = this.audioContext.createScriptProcessor(4096, 1, 1);
this.recorder.onaudioprocess = (e) => {
this.audioData.inputData(e.inputBuffer.getChannelData(0));
};
}
// 开始录音
start = (callback = null) => {
this.audioInput.connect(this.audioVolume);
this.audioVolume.connect(this.recorder);
this.recorder.connect(this.audioContext.destination);
callback && this.cycle((data) => callback(data));
};
// 停止录音
stop = () => {
this.recorder.disconnect();
clearTimeout(this.clock);
};
// 暂停录音
pause = () => {
this.audioVolume.disconnect();
clearTimeout(this.clock);
};
// 继续录音
continue = (callback = null) => {
this.recorder.connect(this.audioContext.destination);
callback && this.cycle((data) => callback(data));
};
// 获取 WAV 数据
getWav = () => {
return this.audioData.encodeWAV();
};
// 清除缓冲区
clear = () => {
this.audioData.clearData();
};
// 循环拉取缓冲数据,使用 `callback()` 发送出去,该方法适用于流
cycle = (callback, time = 500) => {
let bTime = new Date();
callback(this.getWav());
this.clear();
this.clock = setTimeout(
() => this.cycle((data) => callback(data), time),
time - (new Date() - bTime)
);
};
}