简介
最近由于工作需要,需要在react上用到一个录音的功能,录音主要包含开始录音,暂停录音,停止录音,并将频谱通过canvas绘制出来。起初开发时找了一个现成的包,但是这个第三方的包不支持暂停功能,也不支持音频转码,只能输出audio/webm格式,所以自己在周末决定重新写一个关于react录音的插件。
使用
目前这个包已经上传至npm,需要用的同学可以运行指令
npm install react-audio-analyser --save
下载到本地,更多详细的使用方法请看这里。欢迎大家使用,也希望多多提issue。有兴趣的同学可以继续往下看,文章接下来会详细讲述一下录音的实现及开发过程。
项目简介(react-audio-analyser)
项目本身主要在2个文件夹,component就是组件react-audio-analyser存放的位置。
component:
- audioConvertWav.js audio/webm转audio/wav
- index.js 外层的index.js用于暴露组件,内层index为组件的容器(组建本身)
- MediaRecorder.js 组件录音主要处理逻辑。
- RenderCanvas.js 音频曲线绘制处理逻辑。
- index.css 暂未启用
demo:
- demo主要用于对组件的演示,主要包含控制按钮(开始,暂停,结束)的渲染,及逻辑处理。
react-audio-analyser
index.js
import React, {Component} from "react";
import MediaRecorder from "./MediaRecorder";
import RenderCanvas from "./RenderCanvas";
import "./index.css";
@MediaRecorder
@RenderCanvas
class AudioAnalyser extends Component {
componentDidUpdate(prevProps) { // 检测传入status的变化
if (this.props.status !== prevProps.status) {
const event = {
inactive: this.stopAudio,
recording: this.startAudio,
paused: this.pauseAudio
}[this.props.status];
event && event();
}
}
render() {
const {
children, className, audioSrc
} = this.props;
return (
<div className={className}>
<div>
{this.renderCanvas()} // canvas 渲染
</div>
{children} // 控制按钮
{
audioSrc &&
<div>
<audio controls src={audioSrc}/>
</div>
}
</div>
);
}
}
AudioAnalyser.defaultProps = {
status: "", //组件状态
audioSrc: "", //音频资源URL
backgroundColor: "rgba(0, 0, 0, 1)", //背景色
strokeColor: "#ffffff", //音频曲线颜色
className: "audioContainer", //样式类
audioBitsPerSecond: 128000, //音频码率
audioType: "audio/webm", //输出格式
width: 500, //canvas宽
height: 100 //canvas高
};
export default AudioAnalyser;
组件的大体思路是,在src/component/AudioAnalyser/index.js 中渲染音频canvas,以及通过插槽的方式去将控制按钮渲染进来,这样做的好处是,使用组件的人可以自主的控制按钮样式,也暴露了组件的样式类,供父级传入新的样式类来修改整个组件的样式。
因此关于组件的开始,暂停,停止等状态的触发,也是由具体使用组件时提供的按钮来改变状态,传入组件,组件本身通过对props的更改来触发相关的钩子。
组件挂载了2个装饰器,分别是MediaRecorder,RenderCanvas
这两个装饰器分别用于处理音频逻辑和渲染canvas曲线。装饰器本身继承了当前挂载的类,使得上下文被打通,更有利于属性方法的调用。
MediaRecorder
/**
* @author j_bleach 2018/8/18
* @describe 媒体记录(包含开始,暂停,停止等媒体流及回调操作)
* @param Target 被装饰类(AudioAnalyser)
*/
import convertWav from "./audioConvertWav";
const MediaRecorderFn = Target => {
const constraints = {audio: true};
return class MediaRecorderClass extends Target {
static audioChunk = [] // 音频信息存储对象
static mediaRecorder = null // 媒体记录对象
static audioCtx = new (window.AudioContext || window.webkitAudioContext)(); // 音频上下文
constructor(props) {
super(props);
MediaRecorderClass.compatibility();
this.analyser = MediaRecorderClass.audioCtx.createAnalyser();
}
/**
* @author j_bleach 2018/08/02 17:06
* @describe 浏览器navigator.mediaDevices兼容性处理
*/
static compatibility() {
const promisifiedOldGUM = (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;
}
}
/**
* @author j_bleach 2018/8/19
* @describe 验证函数,如果存在即执行
* @param fn: function 被验证函数
* @param e: object 事件对象 event object
*/
static checkAndExecFn(fn, e) {
typeof fn === "function" && fn(e)
}
/**
* @author j_bleach 2018/8/19
* @describe 音频流转blob对象
* @param type: string 音频的mime-type
* @param cb: function 录音停止回调
*/
static audioStream2Blob(type, cb) {
let wavBlob = null;
const chunk = MediaRecorderClass.audioChunk;
const audioWav = () => {
let fr = new FileReader();
fr.readAsArrayBuffer(new Blob(chunk, {type}))
let frOnload = (e) => {
const buffer = e.target.result
MediaRecorderClass.audioCtx.decodeAudioData(buffer).then(data => {
wavBlob = new Blob([new DataView(convertWav(data))], {
type: "audio/wav"
})
MediaRecorderClass.checkAndExecFn(cb, wavBlob);
})
}
fr.onload = frOnload
}
switch (type) {
case "audio/webm":
MediaRecorderClass.checkAndExecFn(cb, new Blob(chunk, {type}));
break;
case "audio/wav":
audioWav();
break;
default:
return void 0
}
}
/**
* @author j_bleach 2018/8/18
* @describe 开始录音
*/
startAudio = () => {
const recorder = MediaRecorderClass.mediaRecorder;
if (!recorder || (recorder && recorder.state === "inactive")) {
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
this.recordAudio(stream);
}).catch(err => {
throw new Error("getUserMedia failed:", err);
}
)
return false
}
if (recorder && recorder.state === "paused") {
MediaRecorderClass.resumeAudio();
}
}
/**
* @author j_bleach 2018/8/19
* @describe 暂停录音
*/
pauseAudio = () => {
const recorder = MediaRecorderClass.mediaRecorder;
if (recorder && recorder.state === "recording") {
recorder.pause();
recorder.onpause = () => {
MediaRecorderClass.checkAndExecFn(this.props.pauseCallback);
}
MediaRecorderClass.audioCtx.suspend();
}
}
/**
* @author j_bleach 2018/8/18
* @describe 停止录音
*/
stopAudio = () => {
const {audioType} = this.props;
const recorder = MediaRecorderClass.mediaRecorder;
if (recorder && ["recording", "paused"].includes(recorder.state)) {
recorder.stop();
recorder.onstop = () => {
MediaRecorderClass.audioStream2Blob(audioType, this.props.stopCallback);
MediaRecorderClass.audioChunk = []; // 结束后,清空音频存储
}
MediaRecorderClass.audioCtx.suspend();
this.initCanvas();
}
}
/**
* @author j_bleach 2018/8/18
* @describe mediaRecorder音频记录
* @param stream: binary data 音频流
*/
recordAudio(stream) {
const {audioBitsPerSecond, mimeType} = this.props;
MediaRecorderClass.mediaRecorder = new MediaRecorder(stream, {audioBitsPerSecond, mimeType});
MediaRecorderClass.mediaRecorder.ondataavailable = (event) => {
MediaRecorderClass.audioChunk.push(event.data);
}
MediaRecorderClass.audioCtx.resume();
MediaRecorderClass.mediaRecorder.start();
MediaRecorderClass.mediaRecorder.onstart = (e) => {
MediaRecorderClass.checkAndExecFn(this.props.startCallback, e);
}
MediaRecorderClass.mediaRecorder.onresume = (e) => {
MediaRecorderClass.checkAndExecFn(this.props.startCallback, e);
}
MediaRecorderClass.mediaRecorder.onerror = (e) => {
MediaRecorderClass.checkAndExecFn(this.props.errorCallback, e);
}
const source = MediaRecorderClass.audioCtx.createMediaStreamSource(stream);
source.connect(this.analyser);
this.renderCurve(this.analyser);
}
/**
* @author j_bleach 2018/8/19
* @describe 恢复录音
*/
static resumeAudio() {
MediaRecorderClass.audioCtx.resume();
MediaRecorderClass.mediaRecorder.resume();
}
}
}
export default MediaRecorderFn;
这个装饰器主要使用到了navigator.mediaDevices.getUserMedia
和MediaRecorder
这两个api,navigator.mediaDevices.getUserMedia是用于调用硬件设备的api,在对麦克风摄像头进行操作时都需要用到这个。之前在做视频相关开发的时候,还用到了mediaDevices下的MediaDevices.ondevicechange和navigator.mediaDevices.enumerateDevices这两个方法分别用来检测输入硬件变化,以及硬件设备列表查询,这次音频没有用这两个方法,原因是我观察到开发时大多设备都默认包含有音频输入,要求不像视频那么严格,所以本组件只做了navigator.mediaDevices的兼容处理,有想法的同学可以把这两个方法也加上。
在对音频做记录时,主要应用到的一个api是MediaRecorder
,这个api对浏览器有一定的要求,目前只支持谷歌以及火狐。
MediaRecorder 主要有4种回调,MediaRecorder.pause(),MediaRecorder.resume(),MediaRecorder.start(),MediaRecorder.stop(),分别对应于录音的4种状态。
该装饰器包含三个关键的回调函数:startAudio,pauseAudio,stopAudio
。用于对各状态的处理,触发条件就是通过改变传入组件的status属性,本组件在开发过程中没有对开始和恢复的回调进行区别,这可能是一个遗漏的地方,需要的同学只能在上层状态机改变时自行区分了。
RenderCanvas
在MediaRecorder.js中,当开始录音后,会通过AudioContext将设备输入的音频流,创建为一个音频资源对象,然后将这个对象关联至AnalyserNode(一个用于音频可视化的分析对象)。即
const source = MediaRecorderClass.audioCtx.createMediaStreamSource(stream);
source.connect(this.analyser);
在组件挂载时期,初始化一块黑色背景白色中线的画布。
configCanvas() {
const {height, width, backgroundColor, strokeColor} = this.props;
const canvas = RenderCanvasClass.canvasRef.current;
RenderCanvasClass.canvasCtx = canvas.getContext("2d");
RenderCanvasClass.canvasCtx.clearRect(0, 0, width, height);
RenderCanvasClass.canvasCtx.fillStyle = backgroundColor;
RenderCanvasClass.canvasCtx.fillRect(0, 0, width, height);
RenderCanvasClass.canvasCtx.lineWidth = 2;
RenderCanvasClass.canvasCtx.strokeStyle = strokeColor;
RenderCanvasClass.canvasCtx.beginPath();
}
这个画布用于组件初始化显示,以及停止之后的恢复状态。
在开启录音后,首先创建一个可视化无符号8位的类型数组,数组长度为analyserNode的fftsize(fft:快速傅里叶变换)长度,默认为2048。然后通过analyserNode的getByteTimeDomainData这个api,将音频信息存储在刚刚创建的类型数组上。这样就可以得到一个带有音频信息,且长度为2048的类型数组,将canvas画布的宽度分割为2048份,然后有画布左边中点为圆点,开始根据数组的值为高来绘制音频曲线,即:
renderCurve = () => {
const {height, width} = this.props;
RenderCanvasClass.animationId = window.requestAnimationFrame(this.renderCurve); // 定时动画
const bufferLength = this.analyser.fftSize; // 默认为2048
const dataArray = new Uint8Array(bufferLength);
this.analyser.getByteTimeDomainData(dataArray);// 将音频信息存储在长度为2048(默认)的类型数组(dataArray)
this.configCanvas();
const sliceWidth = Number(width) / bufferLength;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * height / 2;
RenderCanvasClass.canvasCtx[i === 0 ? "moveTo" : "lineTo"](x, y);
x += sliceWidth;
}
RenderCanvasClass.canvasCtx.lineTo(width, height / 2);
RenderCanvasClass.canvasCtx.stroke();
}
通过requestAnimationFrame这个api来实现动画效果,这是一个做动画渲染常用到的api,最近做地图路径导航也用到了这个渲染,他比setTimeout在渲染视图上有着更好的性能,需要注意的点和定时器一样,就是在结束选然后,一个要手动取消动画,即:
window.cancelAnimationFrame(RenderCanvasClass.animationId);
至此,关于音频曲线的绘制就结束了,项目本身还是有一些小的细节待改进,也有一些小的迭代会更新上去,比如新的音频格式,新的曲线展示等等,更多请关注git更新。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。