AboutFE icon indicating copy to clipboard operation
AboutFE copied to clipboard

24、H5相关的功能

Open CodingMeUp opened this issue 7 years ago • 0 comments

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()接受三个参数:constrainssuccessCallbackerrorCallback

constrains参数是一个MediaStreamConstraints对象,指定了请求使用媒体的类型,还有每个类型的所需要的参数。我们可以简单的使用布尔值指定是请求音频还是视频:

{ audio: true, video: true }

successCallback参数是一个函数,当成功获取用户的授权之后,函数就会被调用,接受一个MediaStream对象作为函数的参数,我们可以在这个函数中对媒体源进行一些操作。

errorCallback参数也是一个函数,当用户拒绝给予设备访问的许可,或者没有可用的媒体设备资源时就会被调用,一个错误对象将会作为它的参数。

需要注意的是,如果用户没有对设备授权请求做任何操作(允许或拒绝),successCallbackerrorCallback都不会被调用。

目前getUserMedia()方法有一个在MediaDevices接口上的实现,这其实是navigator.getUserMedia()的代替。这两者的区别在于,MediaDevices.getUserMedia()方法会在用户做出授权操作之后,返回一个Promise对象,MediaStream对象会作为这个PromiseResolved状态的回调函数参数,相应的,如果用户拒绝了许可或者没有可用媒体设备资源的情况下,这个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,或者使用MediaRecorderAPI对其进行保存。

在这里,我们使用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()接受三个参数:bufferSizenumberOfInputChannelsnumberOfOutputChannels,分别代表缓冲区大小、输入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

除了使用AudioContextMediaRecorder也可以进行媒体的录制。

MediaRecorderMediaStream 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中进行 数据编码或者转换成相应格式的文件等耗时的操作,并在操作完成后将数据返回主线程进行保存或上传的处理。

将数据转换成相应格式的文件

这个可根据具体需求进行处理,目前网上也有很多相应的算法和转换库,将获取到的数据调用相应的算法转换即可,不再赘述。

一些问题

上面大致描述了如何使用getUserMediaAudioContext等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/

CodingMeUp avatar Jan 08 '18 06:01 CodingMeUp