yiyi-audio-recorder
Version:
js audio recorder plugin
564 lines (507 loc) • 18.7 kB
text/typescript
import { WORKER_SOURCE } from "../constants/config";
import { compress, encodePCM, encodeWAV } from "../transform/transform";
import transWorker from "../transform/transform.worker";
declare let window: any;
declare let Math: any;
declare let navigator: any;
declare let Promise: any;
// 构造函数参数格式
interface recorderConfig {
sampleBits?: number; // 采样位数
sampleRate?: number; // 采样率
numChannels?: number; // 声道数
compiling?: boolean; // 是否边录边播
}
export default class Recorder {
private context: any;
protected config: recorderConfig; // 配置
private analyser: any;
private size: number = 0; // 录音文件总长度
private lChannelData: Array<Float32Array> = [];
private lBuffer: Array<Float32Array> = []; // pcm音频数据搜集器(左声道)
private rBuffer: Array<Float32Array> = []; // pcm音频数据搜集器(右声道)
private PCM: any; // 最终的PCM数据缓存,避免多次encode
private tempPCM: Array<DataView> = []; // 边录边转时临时存放pcm的
private currentWorker: any;
private isWorker: Boolean = false; //是否采用worker进行边录边转
private audioInput: any;
protected inputSampleRate: number; // 输入采样率
protected inputSampleBits: number = 16; // 输入采样位数
protected outputSampleRate: number; // 输出采样率
protected oututSampleBits: number; // 输出采样位数
private source: any; // 音频输入
private recorder: any;
private stream: any; // 流
protected littleEdian: boolean; // 是否是小端字节序
protected fileSize: number = 0; // 录音大小,byte为单位
protected duration: number = 0; // 录音时长
private needRecord: boolean = true; // 由于safari问题,导致使用该方案代替disconnect/connect方案
// 正在录音时间,参数是已经录了多少时间了
public onprocess: (duration: number) => void;
// onprocess 替代函数,保持原来的 onprocess 向下兼容
public onprogress: (payload: {
duration: number;
fileSize: number;
vol: number;
e:any,
lChannelDataList:Array<Float32Array>;
lChannelData: any;
data: Array<DataView>; // 当前存储的所有录音数据
}) => void;
public onplay: () => void; // 音频播放回调
public onpauseplay: () => void; // 音频暂停回调
public onresumeplay: () => void; // 音频恢复播放回调
public onstopplay: () => void; // 音频停止播放回调
public onplayend: () => void; // 音频正常播放结束
/**
* @param {Object} options 包含以下三个参数:
* sampleBits,采样位数,一般8,16,默认16
* sampleRate,采样率,一般 11025、16000、22050、24000、44100、48000,默认为浏览器自带的采样率
* numChannels,声道,1或2
*/
constructor(options: recorderConfig = {}) {
// 临时audioContext,为了获取输入采样率的
let context = new (window.AudioContext || window.webkitAudioContext)();
this.inputSampleRate = context.sampleRate; // 获取当前输入的采样率
//是否启用webworker
if (options.compiling) {
this.isWorker = options.compiling;
this.currentWorker = new transWorker();
this.currentWorker.onmessage = (e: any) => {
// 边录边转处理
let { source, payload } = e.data;
if (source !== WORKER_SOURCE) {
return;
}
let { pcm } = payload;
this.tempPCM.push(pcm);
// 计算录音大小
this.fileSize = pcm.byteLength * this.tempPCM.length;
};
}
// 设置输出配置
this.setNewOption(options);
// 判断端字节序
this.littleEdian = (function () {
let buffer = new ArrayBuffer(2);
new DataView(buffer).setInt16(0, 256, true);
return new Int16Array(buffer)[0] === 256;
})();
// 兼容 getUserMedia
Recorder.initUserMedia();
}
protected setNewOption(options: recorderConfig = {}) {
this.config = {
// 采样数位 8, 16
sampleBits: ~[8, 16].indexOf(options.sampleBits)
? options.sampleBits
: 16,
// 采样率
sampleRate: ~[
8000, 11025, 16000, 22050, 24000, 44100, 48000,
].indexOf(options.sampleRate)
? options.sampleRate
: this.inputSampleRate,
// 声道数,1或2
numChannels: ~[1, 2].indexOf(options.numChannels)
? options.numChannels
: 1,
// 是否需要边录边转,默认关闭,后期使用web worker
// compiling: !!options.compiling || false, // 先移除
};
// 设置采样的参数
this.outputSampleRate = this.config.sampleRate; // 输出采样率
this.oututSampleBits = this.config.sampleBits; // 输出采样数位 8, 16
}
/**
* 开始录音
*
* @returns {Promise<{}>}
* @memberof Recorder
*/
startRecord(): Promise<{}> {
if (this.context) {
// 关闭先前的录音实例,因为前次的实例会缓存少量前次的录音数据
this.destroyRecord();
}
// 初始化
this.initRecorder();
return navigator.mediaDevices
.getUserMedia({
audio: true,
})
.then(
(stream) => {
// audioInput表示音频源节点
// stream是通过navigator.getUserMedia获取的外部(如麦克风)stream音频输出,对于这就是输入
this.audioInput =
this.context.createMediaStreamSource(stream);
this.stream = stream;
} /* 报错丢给外部使用者catch,后期可在此处增加建议性提示
, error => {
// 抛出异常
Recorder.throwError(error.name + " : " + error.message);
} */
)
.then(() => {
// audioInput 为声音源,连接到处理节点 recorder
this.audioInput.connect(this.analyser);
this.analyser.connect(this.recorder);
// this.audioInput.connect(this.recorder);
// 处理节点 recorder 连接到扬声器
this.recorder.connect(this.context.destination);
});
}
/**
* 暂停录音
*
* @memberof Recorder
*/
pauseRecord(): void {
this.needRecord = false;
}
/**
* 继续录音
*
* @memberof Recorder
*/
resumeRecord(): void {
this.needRecord = true;
}
/**
* 停止录音
*
*/
stopRecord(): void {
this.audioInput && this.audioInput.disconnect();
this.source && this.source.stop();
this.recorder.disconnect();
this.analyser.disconnect();
this.needRecord = true;
}
/**
* 销毁录音对象
*
*/
destroyRecord(): Promise<{}> {
this.clearRecordStatus();
// 结束流
this.stopStream();
return this.closeAudioContext();
}
getAnalyseData() {
let dataArray = new Uint8Array(this.analyser.frequencyBinCount);
// 将数据拷贝到dataArray中。
this.analyser.getByteTimeDomainData(dataArray);
return dataArray;
}
// 获取录音数据
getData() {
let data: any = this.flat();
return data;
}
/**
* 清除状态
*
*/
private clearRecordStatus() {
this.lBuffer.length = 0;
this.rBuffer.length = 0;
this.size = 0;
this.fileSize = 0;
this.PCM = null;
this.tempPCM = [];
this.lChannelData = []
this.audioInput = null;
this.duration = 0;
}
/**
* 将二维数组转一维
*
* @private
* @returns {float32array} 音频pcm二进制数据
* @memberof Recorder
*/
private flat(
lBuffer: Array<Float32Array> = [],
rBuffer: Array<Float32Array> = [],
size: number = 0
) {
if (size) {
let lData = null,
rData = new Float32Array(0); // 右声道默认为0
// 创建存放数据的容器
if (1 === this.config.numChannels) {
lData = new Float32Array(size);
} else {
lData = new Float32Array(size / 2);
rData = new Float32Array(size / 2);
}
// 合并
let offset = 0; // 偏移量计算
// 将二维数据,转成一维数据
// 左声道
for (let i = 0; i < lBuffer.length; i++) {
lData.set(lBuffer[i], offset);
offset += lBuffer[i].length;
}
offset = 0;
// 右声道
for (let i = 0; i < rBuffer.length; i++) {
rData.set(rBuffer[i], offset);
offset += rBuffer[i].length;
}
return {
left: lData,
right: rData,
};
} else {
let lData = null,
rData = new Float32Array(0); // 右声道默认为0
// 创建存放数据的容器
if (1 === this.config.numChannels) {
lData = new Float32Array(this.size);
} else {
lData = new Float32Array(this.size / 2);
rData = new Float32Array(this.size / 2);
}
// 合并
let offset = 0; // 偏移量计算
// 将二维数据,转成一维数据
// 左声道
for (let i = 0; i < this.lBuffer.length; i++) {
lData.set(this.lBuffer[i], offset);
offset += this.lBuffer[i].length;
}
offset = 0;
// 右声道
for (let i = 0; i < this.rBuffer.length; i++) {
rData.set(this.rBuffer[i], offset);
offset += this.rBuffer[i].length;
}
return {
left: lData,
right: rData,
};
}
}
/**
* 初始化录音实例
*/
private initRecorder(): void {
// 清空数据
this.clearRecordStatus();
this.context = new (window.AudioContext || window.webkitAudioContext)();
this.analyser = this.context.createAnalyser(); // 录音分析节点
this.analyser.fftSize = 2048; // 表示存储频域的大小
// 第一个参数表示收集采样的大小,采集完这么多后会触发 onaudioprocess 接口一次,该值一般为1024,2048,4096等,一般就设置为4096
// 第二,三个参数分别是输入的声道数和输出的声道数,保持一致即可。
let createScript =
this.context.createScriptProcessor ||
this.context.createJavaScriptNode;
this.recorder = createScript.apply(this.context, [
4096,
this.config.numChannels,
this.config.numChannels,
]);
// 音频采集
this.recorder.onaudioprocess = (e) => {
if (!this.needRecord) {
return;
}
// 左声道数据
// getChannelData返回Float32Array类型的pcm数据
let lData = e.inputBuffer.getChannelData(0),
rData = null,
vol = 0; // 音量百分比
this.lChannelData.push(lData);
this.lBuffer.push(new Float32Array(lData));
let currentDataSize = lData.length;
let currentLeftBuffer: Array<Float32Array> = [];
currentLeftBuffer.push(new Float32Array(lData));
let currentRightBuffer: Array<Float32Array> = [];
this.size += lData.length;
// 判断是否有右声道数据
if (2 === this.config.numChannels) {
rData = e.inputBuffer.getChannelData(1);
this.rBuffer.push(new Float32Array(rData));
currentRightBuffer.push(new Float32Array(rData));
this.size += rData.length;
currentDataSize += rData.length;
}
let config = {
inputSampleRate: this.inputSampleRate,
outputSampleRate: this.outputSampleRate,
oututSampleBits: this.oututSampleBits,
littleEdian: this.littleEdian,
};
if (this.isWorker) {
this.currentWorker.postMessage({
source: WORKER_SOURCE,
payload: {
audioData: this.flat(
currentLeftBuffer,
currentRightBuffer,
currentDataSize
),
config,
},
});
}
// 边录边转处理 暂时不支持
// if (this.config.compiling) {
// let pcm = this.transformIntoPCM(lData, rData);
// this.tempPCM.push(pcm);
// // 计算录音大小
// this.fileSize = pcm.byteLength * this.tempPCM.length;
// } else {
// 计算录音大小
if (!this.config.compiling) {
this.fileSize =
Math.floor(
this.size /
Math.max(
this.inputSampleRate / this.outputSampleRate,
1
)
) *
(this.oututSampleBits / 8);
}
// }
// 为何此处计算大小需要分开计算。原因是先录后转时,是将所有数据一起处理,边录边转是单个 4096 处理,
// 有小数位的偏差。
// 计算音量百分比
vol = Math.max.apply(Math, lData) * 100;
// 统计录音时长
this.duration += 4096 / this.inputSampleRate;
// 录音时长回调
this.onprocess && this.onprocess(this.duration);
// 录音时长及响度回调
this.onprogress &&
this.onprogress({
duration: this.duration,
fileSize: this.fileSize,
vol,
e:e,
lChannelData:lData,
lChannelDataList: this.lChannelData,
data: this.tempPCM, // 当前所有的pcm数据,调用者控制增量
});
};
}
/**
* 终止流(这可以让浏览器上正在录音的标志消失掉)
* @private
* @memberof Recorder
*/
private stopStream() {
if (this.stream && this.stream.getTracks) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
}
/**
* close兼容方案
* 如firefox 30 等低版本浏览器没有 close方法
*/
private closeAudioContext() {
if (
this.context &&
this.context.close &&
this.context.state !== "closed"
) {
return this.context.close();
} else {
return new Promise((resolve) => {
resolve();
});
}
}
// getUserMedia 版本兼容
static initUserMedia() {
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = function (constraints) {
let getUserMedia =
navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia;
if (!getUserMedia) {
return Promise.reject(
new Error("浏览器不支持 getUserMedia !")
);
}
return new Promise(function (resolve, reject) {
getUserMedia.call(navigator, constraints, resolve, reject);
});
};
}
}
/**
* 将获取到到左右声道的Float32Array数据编码转化
*
* @param {Float32Array} lData 左声道数据
* @param {Float32Array} rData 有声道数据
* @returns DataView
*/
private transformIntoPCM(lData, rData) {
let lBuffer = new Float32Array(lData),
rBuffer = new Float32Array(rData);
let data = compress(
{
left: lBuffer,
right: rBuffer,
},
this.inputSampleRate,
this.outputSampleRate
);
return encodePCM(data, this.oututSampleBits, this.littleEdian);
}
static getPermission(): Promise<{}> {
this.initUserMedia();
return navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
stream && stream.getTracks().forEach((track) => track.stop());
});
}
/**
* 获取边录边转后最终的PCM
*
*/
protected getEncodedPCM(): DataView | null {
if (this.PCM) {
return this.PCM;
} else if (this.tempPCM.length) {
let pcm = this.mergeDataView(this.tempPCM);
this.PCM = pcm;
return pcm;
} else {
return null;
}
}
/**
* 合并多个pcm
*/
private mergeDataView = (arr: Array<DataView>) => {
let allLen = 0;
for (let i = 0; i < arr.length; i++) {
allLen += arr[i].byteLength;
}
let buffer = new ArrayBuffer(allLen);
let view = new DataView(buffer);
let offset = 0;
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr[i].byteLength; j += 2, offset += 2) {
view.setInt16(
offset,
arr[i].getInt16(j, this.littleEdian),
this.littleEdian
);
}
}
return view;
};
}