AboutFE
AboutFE copied to clipboard
24、H5相关的功能
AUDIO
前一段时间的一个案子是开发一个有声课件,大致就是通过导入文档、图片等资源后,页面变为类似 PPT 的布局,然后选中一张图片,可以插入音频,有单页编辑和全局编辑两种模式。其中音频的导入方式有两种,一种是从资源库中导入,还有一种就是要提到的录音。
说实话,一开始都没接触过 HTML5 的 Audio API,而且要基于在我们接手前的代码中进行优化。当然其中也踩了不少坑,这次也会围绕这几个坑来说说感受(会省略一些基本对象的初始化和获取,因为这些内容不是这次的重点,有兴趣的同学可以自行查找 MDN 上的文档):
- 调用 Audio API 的兼容性写法
- 获取录音声音的大小(应该是频率)
- 暂停录音的兼容性写法
- 获取当前录音时间
录音前的准备
开始录音前,要先获取当前设备是否支持 Audio API。早期的方法 navigator.getUserMedia
已经被 navigator.mediaDevices.getUserMedia
所代替。正常来说现在大部分的现代浏览器都已经支持navigator.mediaDevices.getUserMedia
的用法了,当然MDN
上也给出了兼容性的写法
const promisifiedOldGUM = function (constraints) {
// First get ahold of getUserMedia, if present
const getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia)
// Some browsers just don't implement it - return a rejected promise with an error
// to keep a consistent interface
if (!getUserMedia) {
return Promise.reject(new Error('getUserMedia is not implemented in this browser'))
}
// Otherwise, wrap the call to the old navigator.getUserMedia with a Promise
return new Promise(function (resolve, reject) {
getUserMedia.call(navigator, constraints, resolve, reject)
})
}
// Older browsers might not implement mediaDevices at all, so we set an empty object first
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {}
}
// Some browsers partially implement mediaDevices. We can't just assign an object
// with getUserMedia as it would overwrite existing properties.
// Here, we will just add the getUserMedia property if it's missing.
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = promisifiedOldGUM
}
因为这个方法是异步的,所以我们可以对无法兼容的设备进行友好的提示
navigator
.mediaDevices
.getUserMedia(constraints)
.then(function (mediaStream) {
// 成功
}, function (error) {
// 失败
const { name } = error
let errorMessage
switch (name) {
// 用户拒绝
case 'NotAllowedError':
case 'PermissionDeniedError':
errorMessage = '用户已禁止网页调用录音设备'
break
// 没接入录音设备
case 'NotFoundError':
case 'DevicesNotFoundError':
errorMessage = '录音设备未找到'
break
// 其它错误
case 'NotSupportedError':
errorMessage = '不支持录音功能'
break
default:
errorMessage = '录音调用错误'
window.console.log(error)
}
return errorMessage
})
一切顺利的话,我们就可以进入下一步了。
(这里有对获取上下文的方法进行了省略,因为这不是这次的重点)
开始录音、暂停录音
这里有个比较特别的点,就是需要添加一个中间变量来标识是否当前是否在录音。因为在火狐浏览器上,我们发现一个问题,录音的流程都是正常的,但是点击暂停时却发现怎么也暂停不了,我们当时是使用 disconnect
方法。这种方式是不行的,这种方法是需要断开所有的连接才可以。后来发现,应该增加一个中间变量 this.isRecording
来判断当前是否正在录音,当点击开始时,将其设置为true
,暂停时将其设置为false
。
当我们开始录音时,会有一个录音监听的事件 onaudioprocess
,如果返回 true
则会将流写入,如果返回 false
则不会将其写入。因此判断this.isRecording
,如果为 false
则直接 return
// 一些初始化
const audioContext = new AudioContext()
const sourceNode = audioContext.createMediaStreamSource(mediaStream)
const scriptNode = audioContext.createScriptProcessor(BUFFER_SIZE, INPUT_CHANNELS_NUM, OUPUT_CHANNELS_NUM)
sourceNode.connect(this.scriptNode)
scriptNode.connect(this.audioContext.destination)
// 监听录音的过程
scriptNode.onaudioprocess = event => {
if (!this.isRecording) return // 判断是否正则录音
this.buffers.push(event.inputBuffer.getChannelData(0)) // 获取当前频道的数据,并写入数组
}
当然这里也会有个坑,就是无法再使用,自带获取当前录音时长的方法了,因为实际上并不是真正的暂停,而是没有将流写入罢了。于是我们还需要获取一下当前录音的时长,需要通过一个公式进行获取
const getDuration = () => {
return (4096 * this.buffers.length) / this.audioContext.sampleRate // 4096为一个流的长度,sampleRate 为采样率
}
这样就能够获取正确的录音时长了。
结束录音
结束录音的方式,我采用的是先暂停,之后需要试听或者其它的操作先执行,然后再将存储流的数组长度置为0。
获取频率
getVoiceSize = analyser => {
const dataArray = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(dataArray)
const data = dataArray.slice(100, 1000)
const sum = data.reduce((a, b) => a + b)
return sum
}
具体可以参考https://developer.mozilla.org/zh-CN/docs/Web/API/AnalyserNode/frequencyBinCount
其它
- HTTPS:在 chrome 下需要全站有
HTTPS
才允许使用 - 微信:在微信内置的浏览器需要调用 JSSDK 才能使用
- 音频格式转换:音频格式的方式也有很多了,能查到的大部分资料,大家基本上是互相 copy,当然还有一个音频质量的问题,这里就不赘述了。
结语
这次遇到的大部分问题都是兼容性的问题,因此在上面踩了不少坑,尤其是移动端的问题,一开始还有出现因为获取录音时长写法错误的问题,导致直接卡死的情况。这次的经历也弥补了 HTML5 API上的一些空白,当然最重要的还是要提醒一下大家,这种原生的 API 文档还是直接查看 MDN 来的简单粗暴!
title: 使用Web API实现在线录音
在开发线上案子审核项目时,我们需要开发一个在线录音的功能。为了完成录音功能的开发,在以往我们可能会借助一些浏览器插件,如Flash
来实现我们的目的。目前大多数现代浏览器都实现了对本地设备硬件访问的支持,这使得我们通过原生的API实现web录音成为可能。
下面将介绍如何使用原生API进行web录音功能的开发,以及可能遇到的问题和解决方案。
getUserMedia()
在getUserMedia()
之前,为了实现从网络访问本地设备,出现过好几种「Media Capture API」的变体。这不是本次介绍的重点,下面简要介绍一下
HTML媒体捕获
通过拓展input
元素,为其增加capture
属性,让input
可以接受用户设备的输入。例子如下:
<input type="file" accept="audio/*" capture="microphone">
在桌面平台上,将会忽略capture
属性,直接通过文件系统上传文件;在 iOS 上的 Safari 中,它会打开麦克风应用以便您录制音频,然后将其传回网页;在 Android 上,它允许用户选择使用哪一个应用来录制音频,录制完毕后将其传回网页。
设备元素
鉴于HTML媒体捕获的局限性,随后推出了一种新的规范,设计了一个新的<device>
元素,也就是getUserMedia
的前身。不久之后WhatWG决定废止<device>
标记,以支持称为navigator.getUserMedia()
的新兴 JavaScript API。所以<device>
只是短暂的被Opera支持了一段时间就消失了。
getUserMedia
依靠WebRTC
的大力协助,媒体捕获API的发展速度加快,各个浏览器公司也在致力于在自己的浏览器上实现这个API。getUserMedia()
与 WebRTC
相关,因为它是通向这组 API 的门户。它提供了访问用户本地相机/麦克风媒体流的手段。
有关WebRTC规范的信息,参看W3C WebRTC 工作组
getUserMedia()
是Navigator
的一个方法。
Navigator
接口表示用户代理的状态和标识。它允许脚本查询它和注册自己进行一些活动。
我们可以使用只读的window.navigator
属性来检索导航器对象。通过调用navigator.getUserMedia()
方法,浏览器会提醒用户需要使用音频和视频输入设备,比如相机,屏幕共享,或者麦克风等。
getUserMedia()
接受三个参数:constrains
,successCallback
,errorCallback
constrains
参数是一个MediaStreamConstraints
对象,指定了请求使用媒体的类型,还有每个类型的所需要的参数。我们可以简单的使用布尔值指定是请求音频还是视频:
{ audio: true, video: true }
successCallback
参数是一个函数,当成功获取用户的授权之后,函数就会被调用,接受一个MediaStream
对象作为函数的参数,我们可以在这个函数中对媒体源进行一些操作。
errorCallback
参数也是一个函数,当用户拒绝给予设备访问的许可,或者没有可用的媒体设备资源时就会被调用,一个错误对象将会作为它的参数。
需要注意的是,如果用户没有对设备授权请求做任何操作(允许或拒绝),
successCallback
和errorCallback
都不会被调用。
目前getUserMedia()
方法有一个在MediaDevices
接口上的实现,这其实是navigator.getUserMedia()
的代替。这两者的区别在于,MediaDevices.getUserMedia()
方法会在用户做出授权操作之后,返回一个Promise
对象,MediaStream
对象会作为这个Promise
的Resolved
状态的回调函数参数,相应的,如果用户拒绝了许可或者没有可用媒体设备资源的情况下,这个Promise
会调用Rejected
状态的回调函数并传入错误对象
虽然navigator.getUserMedia()
是不被规范推荐的,但是从兼容性上来看,navigator.getUserMedia()
是明显优于MediaDevices.getUserMedia()
的。
在项目中,我们使用了navigator.getUserMedia()
,在组件初始化时对getUserMedia()
做兼容性处理,以便支持更多浏览器:
init() {
navigator.getUserMedia = navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia
/* other initial code */
}
AudioContext
我们已经通过getUserMedia()
获取到了媒体信息流,其中包含了来自麦克风的数据(如果开启了摄像头的请求则包含摄像头的数据),然后我们可以将这些数据附加到一个audio
元素,或者将其附加到一个网络音频AudioContext
,或者使用MediaRecorder
API对其进行保存。
在这里,我们使用AudioContext
来处理获取到媒体信息流。
AudioContext接口表示由音频模块连接而成的音频处理图,每个模块对应一个AudioNode。AudioContext可以控制它所包含的节点的创建,以及音频处理、解码操作的执行。做任何事情之前都要先创建AudioContext对象,因为一切都发生在这个环境之中。
AudioContext
提供了强大的功能让我们能够对音频源进行处理,例如复杂的混音,音效,平移等等,详细请查看MDNAudioContext
以及Web Audio API
的相关内容。
初始化
在组件初始化中,创建一个全局的audioContext
实例
init() {
navigator.getUserMedia = navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia
this.audioCtx = !this.audioCtx
? new (window.AudioContext || window.webkitAudioContext)()
: this.audioCtx
/* other initial code */
}
开始录音
在点击开始录音之后,我们调用getUserMedia
,然后在获取授权成功的回调函数中,获取输入源并将输入源连接到可以处理音频数据(调节增益等)节点。
onStart() {
if(navigator.getUserMedia) {
let constrains = { audio: true }
let onSuccess = (stream) => {
this.recordingStream = stream
//媒体流音频源
this.microphone = this.audioCtx.createMediaStreamSource(this.recordingStream)
//js音频处理器
this.processor = this.audioCtx.createScriptProcessor(this.config.bufferSize, this.config.numChannels, this.config.numChannels)
//监听音频录制过程
this.processor.onaudioprocess = (event) => {
// do something
}
this.microphone.connect(this.processor)
this.processor.connect(this.audioCtx.destination)
}
let onError = (error) => {
// error handler
}
// 调用getUserMedia
navigator.getUserMedia(constrains, onSuccess, onError)
} else {
console.log('getUserMedia not supported on your browser')
}
}
在上面代码中,我们使用createMediaSourceStream()
创建了一个MediaStreamAudioSourceNode
对象,这样来自MediaStream
的音频就可以被播放或者操作。
接着使用createScriptProcessor()
创建了一个ScriptProcessorNode
对象,createScriptProcessor()
接受三个参数:bufferSize
,numberOfInputChannels
,numberOfOutputChannels
,分别代表缓冲区大小、输入node的声道数量、输出node的声道数量
ScriptProcessorNode
接口允许使用javaScript生成、处理、分析音频. 它是一个AudioNode
, 连接着两个缓冲区音频处理模块, 其中一个缓冲区包含输入音频数据,另外一个包含处理后的输出音频数据. 实现了AudioProcessingEvent
接口的一个事件,每当输入缓冲区有新的数据时,事件将被发送到该对象,并且事件将在数据填充到输出缓冲区后结束.
我们将音频源连接到ScriptProcessorNode
节点上,每次音频缓冲区已满,需要我们进行处理时,这个节点都会发出一个onaudioprocess
事件,可以在这个事件的回调函数中,将音频数据保留到指定的地方,方便随后使用。
为了方便后续的操作,我们将音频源、音频处理器作为整个组件的全局变量进行保存。
暂停/继续 录音
我们需要提供一个暂停/继续录音的功能。为了暂停录音,需要将媒体流音频源断开,同时将音频处理器断开,防止继续接受从音频源过来的数据;相反的,当继续录音时,将音频源重新连接到音频处理器上,音频处理器发出接收音频数据并发出onaudioprocess
事件,然后保存这些音频数据
onPause() {
if(this.processor && this.microphone && this.state.recording) {
if(!this.state.pausing) {
this.setState({
pausing: true
})
this.microphone.disconnect()
this.processor.disconnect()
this.props.onPause && this.props.onPause()
} else {
this.setState({
pausing: false
})
this.microphone.connect(this.processor)
this.processor.connect(this.audioCtx.destination)
this.props.onResume && this.props.onResume()
}
}
}
结束录音
结束录音时,将音频源和音频处理器断开,并停止播放轨道对应的源。
onStop() {
if(this.processor && this.microphone && this.state.recording) {
this.setState({
recording: false,
pausing: false
})
this.microphone.disconnect()
this.processor.disconnect()
this.realTimeWorker.postMessage({
cmd: 'export'
})
this.recordingStream && this.recordingStream.getTracks()[0].stop()
}
}
MediaRecorder
除了使用AudioContext
,MediaRecorder
也可以进行媒体的录制。
MediaRecorder
是MediaStream Recording API
提供的用来进行媒体轻松录制的接口, 他需要通过调用 MediaRecorder() 构造方法进行实例化
MediaRecorder
的使用相对与AudioContext
来说要简单的多,我们不需要再处理音频源或者轨道什么乱七八糟的东西,直接使用它封装好的方法start()
、pause()
、resume()
、stop()
、requestData()
就可以实现媒体的录制、暂停、继续录制、停止录制、获取录制内容等操作,同时也可以通过监听ondataavailable
事件来获取实时可用的数据。
但是MediaRecorder
在兼容性上不是很好。
使用web worker处理音频数据
通过getUserMedia()
和AudioContext
,我们已经能够获取到音频的源数据,对于这些数据,我们在处理上有几种选择:
- 将其直接上传至服务器
- 将其存储在本地
- 将其转换成为指定的文件格式,然后再保存到服务器或者本地
显然,我们要选择第三种处理方式。
通常从麦克风等设备获取到的原始数据的数据量是很大的,如果我们直接在主线程上处理,很可能会导致主线程的阻塞甚至是浏览器崩溃。所以需要建立一个worker线程来处理这些音频数据。
在录音组件中,我们在组件初始化的过程就开启一个worker线程,并对worker进行监听,以便进行实时通信
init() {
/* other initial code */
//开启后台线程
this.realTimeWorker = new Worker()
//主线程监听后台线程,实时通信
this.realTimeWorker.onmessage = (e) => {
switch(e.data.cmd) {
case 'init':
// do something
break
case 'exportPartial': {
// do something
break
}
case 'export': {
if(this.exportBlob) {
this.exportBlob(e.data.blob)
}
break
}
case 'getBuffer': {
let cb = this.getBufferCb
if (cb && typeof cb === 'function') {
cb(e.data.buffer)
}
break
}
default:
console.log(`unknown message: ${e.data}`)
}
}
this.realTimeWorker.onerror = (e) => {
this.errorHandler(e)
}
}
在Worker中监听主线程的消息,并进行相应的处理
// worker.js
//监听主线程
self.onmessage = function (e) {
switch (e.data.cmd) {
case 'init':
init(e.data.config)
break
case 'record':
record(e.data.buffer)
break
case 'export':
exportWAV()
break
case 'exportPartial':
exportWAV(true)
break
case 'getBuffer':
getBuffer()
break
case 'clear':
clear()
break
}
}
可以在worker中进行 数据编码或者转换成相应格式的文件等耗时的操作,并在操作完成后将数据返回主线程进行保存或上传的处理。
将数据转换成相应格式的文件
这个可根据具体需求进行处理,目前网上也有很多相应的算法和转换库,将获取到的数据调用相应的算法转换即可,不再赘述。
一些问题
上面大致描述了如何使用getUserMedia
、AudioContext
等API在web上进行录音,下面说一下在做录音组件的过程中遇到的一些问题。
如何播放录制的数据
要播放录制的数据,不需要转换成相应的文件,只需将数据转换成一个Blob
对象,通过createObjectURL
方法创建一个url,将url赋给相应的<audio>
标签即可
录制文件过大
我们采用将录制数据转换成.wav
格式的方式,使用网上的一个转换算法,在使用的过程中,发现即使只录制十来秒,生成的文件也达到了3、4M!解决的方法是:
- 降低采样率
- 减少通道数
- 采样位数减少
可以参考这里的处理方法。当然可以选择其他的有损压缩算法,转换成其他格式的文件,在保证音频能听清楚的基础上尽量的压缩体积。
长时间录制导致崩溃
这是最容易发生的问题,长时间录制会产生大量的数据,如果在转换成相应格式文件的过程中,有大量的计算,就极有可能发生浏览器崩溃的现象。解决的方法是:
- 如上面所说,尽量将这些会产生大量计算的操作放到worker中去
- 当数据量过大时,考虑分段进行处理
添加多个录音组件时浏览器报错
这是因为创建了多个AudioContext
实例的缘故,当AudioContext
的实例过多时(大概5到6个),浏览器就会报错。通常我们只需要有一个AudioContext
的实例就够了,因此在组件里创建AudioContext
时,先判断是不是有一个全局的,如果没有再创建。
总结
上面简单介绍了如何通过Web API实现在线录音,我们只用到了几个简单的接口和方法。对于流媒体的处理还有很多丰富的接口,能够实现的东西也很多,虽然大多数接口都还在草案、实验阶段,相信这些规范很快就能正式使用。
参考
https://www.w3.org/TR/mediastream-recording/ https://www.w3.org/TR/webaudio/ https://developer.mozilla.org/zh-CN/docs/Web/API/Navigator https://developer.mozilla.org/zh-CN/docs/Web/API/AudioContext https://developer.mozilla.org/zh-CN/docs/Web/API/MediaRecorder https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices https://developer.mozilla.org/en-US/docs/Web/API/MediaStream_Recording_API https://www.html5rocks.com/en/tutorials/getusermedia/intro/ https://www.html5rocks.com/en/tutorials/webaudio/intro/