JavaScript基础修炼(14)——WebRTC在浏览器中如何获得指定格式的PCM数据
博客园地址:
华为云社区地址:
⽬录
本⽂中最重要的信息:32为浮点数表⽰16bit位深数据时是⽤-1+1的⼩数来表⽰16位的-32768+32767的!翻遍了MDN都没到解释,我的内⼼很崩溃!
最近不少朋友需要在项⽬中对接百度语⾳识别的REST API接⼝,在读了我之前写的⼀⽂后仍然对Web⾳频采集和处理的部分⽐较困惑,本⽂仅针对⾳频流处理的部分进⾏解释,全栈实现⽅案的技术要点,可以参见上⾯的博⽂,本篇不再赘述。
⼀. PCM格式是什么
百度语⾳官⽅⽂档对于⾳频⽂件的要求是:
pcm,wav,arm及⼩程序专⽤的m4a格式,要求参数为16000采样率,16bit位深,单声道。
PCM编码,全称为"脉冲编码调制",是⼀种将模拟信号转换成数字信号的⽅法。模拟信号通常指连续的物理量,例如温度、湿度、速度、光照、声响等等,模拟信号在任意时刻都有对应的值;数字信号通常是模拟信号经过采样、量化和编码等⼏个步骤后得到的。
⽐如现在麦克风采集到了⼀段2秒的⾳频模拟信号,它是连续的,我们有⼀个很菜的声卡,采集频率为
10Hz,那么经过采样后就得到了20个离散的数据点,这20个点对应的声⾳值可能是各种精度的,这对于存储和后续的使⽤⽽⾔都不⽅便,此时就需要将这些值也离散化,⽐如在上例中,信号的范围是052dB,假设我们希望将063dB的值都以整数形式记录下来,如果采⽤6个bit位来存储,那么就可以识别(26-1=63)个数值,这样采集的信号通过四舍五⼊后都以整数形式保存就可以了,最⼩精度为1dB;如果⽤7个`bit`位来保存,可存储的不同数值个数为(27-1=127)个,如果同样将0~63dB映射到这个范围上的话,那么最⼩精度就是0.5dB,很明显这样的处理肯定是有精度损失的,使⽤的位数越多精度越⾼,计算机中⾃然需要使⽤8的整数倍的bit位来进⾏存储,经过上述处理后数据就被转换成了⼀串0和1组成的序列,这样的⾳频数据是没有经过任何压缩编码处理的,也被称为“裸流数据”或“原始数据”。从上⾯的⽰例中很容易看出,⽤10Hz的采样率,8bit位存储采样点数值时,记录2秒的数据⼀共会产⽣2X10X8 = 160个bit位,⽽⽤16bit位来存储采样点数据时,记录1秒的数据也会产⽣1X10X16 = 160个bit位,如果没有任何附加的说明信息,就⽆法知道这段数据到底该怎么使⽤。按照指定要求进⾏编码后得到的序列就是pcm数据,它在使⽤之前通常需要声明采集相关的参数。
下图就是⼀段采样率为10Hz,位深为3bit的pcm数据,你可以直观地看到每个步骤所做的⼯作。
wav格式也是⼀种⽆损格式,它是依据规范在pcm数据前添加44字节长度⽤来填充⼀些声明信息的,wav格式可以直接播放。⽽百度语⾳识别接⼝中后两种格式都需要经过编码算法处理,通常会有不同程度的精度损失和体积压缩,所以在使⽤后两种数据时必然会存在额外的编解码时间消耗,所以不难看出,各种格式之间的选择其实就是对时间和空间的权衡。
⼆. 浏览器中的⾳频采集处理
浏览器中的⾳频处理涉及到许多API的协作,相关的概念⽐较多,想要对此深⼊了解的读者可以阅读MDN的篇,本⽂中只做⼤致介绍。
⾸先是实现媒体采集的WebRTC技术,使⽤的旧⽅法是UserMedia( ),新⽅法是UserMedia( ),开发者⼀般需要⾃⼰做⼀下兼容处理,麦克风或摄像头的启⽤涉及到安全隐私,通常⽹页中会有弹框提⽰,⽤户确认后才可启⽤相关功能,调⽤成功后,回调函数中就可以得到多媒体流对象,后续的⼯作就是围绕这个流媒体展开的。
浏览器中的⾳频处理的术语称为AudioGraph,其实就是⼀个【中间件模式】,你需要创建⼀个source节点和⼀个destination节点,然后在它们之间可以连接许许多多不同类型的节点,source节点既可以来⾃流媒体对象,也可以⾃⼰填充⽣成,destination可以连接默认的扬声器端点,也可以连接到媒体录制API MediaRecorder来直接将pcm数据转换为指定媒体编码格式的数据。中间节点的类型有很多种,
可实现的功能也⾮常丰富,包括增益、滤波、混响、声道的合并分离以及⾳频可视化分析等等⾮常多功能(可以参考)。当然想要熟练使⽤还需要⼀些信号处理⽅⾯的知识,对于⾮⼯科背景的开发者⽽⾔并不容易学习。
三. 需求实现
⼀般的实现⽅法是从getUserMedia⽅法得到原始数据,然后根据相关参数⼿动进⾏后处理,相对⽐较繁琐。
⽅案1——服务端FFmpeg实现编码
很多⽰例都是将⾳频源节点直接连接到默认的输出节点(扬声器)上,但是⼏乎没什么意义,笔者⽬前还没有到使⽤Web Audio API⾃动输出pcm原始采样数据的⽅法,可⾏的⽅法是使⽤MediaRecorder来录制⼀段⾳频流,但是录制实例需要传⼊编码相关的参数并指定MIME类型,最终得到的blob对象通常是经过编码后的⾳频数据⽽⾮pcm数据,但也因为经过了编码,这段原始数据的相关参数也就已经存在于输出后的数据中了。百度语⾳官⽅⽂档推荐的⽅法是使⽤ffmpeg在服务端进⾏处理,尽管明显在⾳频的编解码上绕了弯路,但肯定⽐⾃⼰⼿动编码难度要低得多,⽽且ffmepg⾮常强⼤,后续扩展也⽅便。参考数据⼤致从录⾳结束到返回结果,PC端耗时约1秒,移动端约2秒。
核⼼⽰例代码(完整⽰例见附件或开头的github代码仓):
//WebRTC⾳频流采集
.then((stream) => {
//实例化⾳频处理上下⽂
ac = new AudioContext({
sampleRate:16000 //设置采样率
web浏览器在哪里打开});
//创建⾳频处理的源节点
let source = ac.createMediaStreamSource(stream);
//创建⾳频处理的输出节点
let dest = ac.createMediaStreamDestination();
/
/直接连接
//⽣成针对⾳频输出节点流信息的录制实例,如果不通过ac实例调节采样率,也可以直接将stream作为参数
let mediaRecorder = diaRecorder = new MediaRecorder(dest.stream, {
mimeType: '',//chreome中的⾳轨默认使⽤格式为audio/webm;codecs=opus
audioBitsPerSecond: 128000
});
//给录⾳机绑定事件
bindEventsForMediaRecorder(mediaRecorder);
})
.catch(err => {
console.log(err);
});
录⾳机事件绑定:
//给录⾳机绑定事件
function bindEventsForMediaRecorder(mediaRecorder) {
mediaRecorder.addEventListener('start', function (event) {
console.log('start recording!');
});
mediaRecorder.addEventListener('stop', function (event) {
console.log('stop recording!');
});
mediaRecorder.addEventListener('dataavailable', function (event) {
console.log('request data!');
console.log(event.data);//这⾥拿到的blob对象就是编码后的⽂件,既可以本地试听,也可以传给服务端
//⽤a标签下载;
createDownload(event.data);
//⽤audio标签加载
createAudioElement(event.data);
});
}
本地测试时,可以将⽣成的⾳频下载到本地,然后使⽤ffmpeg将其转换为⽬标格式:
ffmpeg -y -i record.webm -f s16le -ac 1 -ar 16000 16k.pcm
详细的参数说明请移步,⾄此就得到了符合百度语⾳识别接⼝的录⾳⽂件。
⽅案2——ScriptProcessorNode⼿动处理数据流
如果觉得使⽤ffmpeg有点“杀鸡⽤⽜⼑”的感觉,那么就需要⾃⼰⼿动处理⼆进制数据了,这是就需要在audioGraph中添加⼀个脚本处理节点scriptProcessorNode,按照MDN的信息该接⼝未来会废弃,⽤新的Audio Worker API取代,但⽬前chrome中的情况是,Audio Worker API标记为试验功能,⽽旧的⽅法也没有明确的提⽰说明会移除(通常计划废除的功能,控制台都会有黄⾊字体的提⽰)。但⽆论如何,相关的基本原理是⼀致的。
scriptProcessorNode节点使⽤⼀个缓冲区来分段存储流数据,每当流数据填充满缓冲区后,这个节点就会触发⼀个audioprocess事件(相当于⼀段chunk),在回调函数中可以获取到该节点输⼊信号和输出信号的内存位置指针,然后通过⼿动操作就可以进⾏数据处理了。
先来看⼀个简单的例⼦,下⾯的⽰例中,处理节点什么都不做,只是把单声道输⼊流直接拷贝到输出流中:
.then((stream) => {
ac = new AudioContext({
sampleRate:16000
});
let source = ac.createMediaStreamSource(stream);
//构造参数依次为缓冲区⼤⼩,输⼊通道数,输出通道数
let scriptNode = ac.createScriptProcessor(4096, 1, 1);
//创建⾳频处理的输出节点
let dest = ac.createMediaStreamDestination();
//串联连接
/
/添加事件处理
//输⼊流位置
var inputBuffer = audioProcessingEvent.inputBuffer;
//输出流位置
var outputBuffer = audioProcessingEvent.outputBuffer;
//遍历通道处理数据,当前只有1个输⼊1个输出
for (var channel = 0; channel < outputBuffer.numberOfChannels; channel++) {
var inputData = ChannelData(channel);
var outputData = ChannelData(channel);
//⽤按钮控制是否记录流信息
if (isRecording) {
for (let i = 0; i < inputData.length; i = i + 1) {
//直接将输⼊的数据传给输出通道
outputData[i] = inputData[i];
}
}
};
}
在上⾯的⽰例加⼯后,如果直接将结果连接到ac.destination(默认的扬声器节点)就可以听到录制的声⾳,你会听到输出信号只是重复了⼀遍输⼊信号。
但是将数据传给outputData输出后是为了在后续的节点中进⾏处理,或者最终作为扬声器或MediaRecorder的输⼊,传出后就⽆法拿到pcm数据了,所以只能⾃⼰来假扮⼀个MediaRecorder。
⾸先在上⾯⽰例中向输出通道透传数据时,改为⾃⼰存储数据,将输⼊数据打印在控制台后可以看到缓冲区⼤⼩设置为4096时,每个chunk中获取到的输⼊数据是⼀个长度为4096的Float32Array定型数组,也就是说每个采样点信息是⽤32位浮点来存储的,给出的转换⽅法如下:
function floatTo16BitPCM(output, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
看起来的确是不知道在⼲嘛,后来参考⽂献中到了相关解释:
32位存储的采样帧数值,是⽤-1到1来映射16bit存储范围-32768~32767的。
现在再来看上⾯的公式就⽐较容易懂了:
//下⾯⼀⾏代码保证了采样帧的值在-1到1之间,因为有可能在多声道合并或其他状况下超出范围
let s = Math.max(-1, Math.min(1, input[i]));
//将32位浮点映射为16位整形表⽰的值
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
如果s>0其实就是将01映射到到032767,正数第⼀位符号位为0,所以32767对应的就是0111 1111 1111 1111也就是0x7FFF,直接把s当系数相乘就可以了;当s为负数时,需要将0-1映
-32768,所以s的值也可以直接当做⽐例系数来进⾏转换计算,负数在内存中存储时需要使⽤补码,补码是原码除符号位以外按位取反再+1得到的,所以-32768原码是1000射到0
0000 0000 0000(溢出的位直接丢弃),除符号位外按位取反得到1111 1111 1111 1111,最后再+1运算得到1000 0000 0000 0000(溢出的位也直接丢弃),⽤16进制表⽰就是0x8000。顺便多说⼀句,补码的存在是为了让正值和负值在⼆进制形态上相加等于0。
公式⾥的output很明显是⼀个ES6-ArrayBuffer中的DataView视图,⽤它可以实现混合形式的内存读写,最后的true表⽰⼩端系统读写,对这⼀块知识不太熟悉的读者可以阅读阮⼀峰前辈的ES6指南(前端必备⼯具书)进⾏了解。
参考⽂献
。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论