## 简介
演示地址 https://jiwenjiang.github.io/
最近由于工作需要, 需要在 react 上用到一个录音的功能, 录音主要包含开始录音, 暂停录音, 停止录音, 并将频谱通过 canvas 绘制出来. 起初开发时找了一个现成的包, 但是这个第三方的包不支持暂停功能, 也不支持音频转码, 只能输出 audio/webm 格式, 所以自己在周末决定重新写一个关于 react 录音的插件. ## 使用 目前这个包已经上传至 npm, 需要用的同学可以运行指令
npm install react-audio-analyser --save
复制代码
下载到本地, 更多详细的使用方法请看这里 https://github.com/jiwenjiang/react-audio-analyser . 欢迎大家使用, 也希望多多提 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 https://developer.mozilla.org/en-US/docs/Web/API/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 https://developer.mozilla.org/en-US/docs/Web/API/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();
- }
复制代码
通过 https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame 这个 api 来实现动画效果, 这是一个做动画渲染常用到的 api, 最近做地图路径导航也用到了这个渲染, 他比 setTimeout 在渲染视图上有着更好的性能, 需要注意的点和定时器一样, 就是在结束选然后, 一个要手动取消动画, 即:
window.cancelAnimationFrame(RenderCanvasClass.animationId);
复制代码
至此, 关于音频曲线的绘制就结束了, 项目本身还是有一些小的细节待改进, 也有一些小的迭代会更新上去, 比如新的音频格式, 新的曲线展示等等, 更多请关注 git 更新.
### 项目地址 https://github.com/jiwenjiang/react-audio-analyser
来源: https://juejin.im/post/5b8273b8f265da434345aa40