##簡介
最近由於工作需要,需要在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更新。
###專案地址
github.com/jiwenjiang/…